bootstrap: iniciar bitácora operativa
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runJSONCommand(t *testing.T, root, keyPath, stdin string, now time.Time, args ...string) ResultEnvelope {
|
||||
t.Helper()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
app := New(Options{
|
||||
Root: root, JSON: true, SSHKey: keyPath, Stdout: &stdout, Stderr: &stderr,
|
||||
Stdin: strings.NewReader(stdin), Now: func() time.Time { return now }, Executable: "clusterlog",
|
||||
})
|
||||
if err := app.Run(args); err != nil {
|
||||
t.Fatalf("command %v failed: %v\nstderr: %s", args, err, stderr.String())
|
||||
}
|
||||
var envelope ResultEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid command JSON for %v: %v\n%s", args, err, stdout.String())
|
||||
}
|
||||
if !envelope.OK {
|
||||
t.Fatalf("command returned non-ok envelope: %#v", envelope)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func envelopeDataMap(t *testing.T, envelope ResultEnvelope) map[string]any {
|
||||
t.Helper()
|
||||
data, ok := envelope.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data type: %T", envelope.Data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestCLIAdminMemoryReviewFlow(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
keyPath := filepath.Join(root, "ana.pub")
|
||||
if err := os.WriteFile(keyPath, []byte(testOpenSSHPublicKey(9, "ana@test")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 12, 14, 0, 0, 123, time.UTC)
|
||||
runJSONCommand(t, root, keyPath, "", now,
|
||||
"admin", "add", "--id", "ana", "--name", "Ana Operadora", "--email", "ana@example.test")
|
||||
|
||||
memoryJSON := `{
|
||||
"title": "Reiniciar controlador de almacenamiento",
|
||||
"description": "Corrección controlada de un controlador degradado",
|
||||
"risk": "high",
|
||||
"tags": ["storage", "mantenimiento"],
|
||||
"systems": ["ceph"],
|
||||
"changes": ["Se drenó el nodo", "Se reinició el controlador"],
|
||||
"validation": ["El clúster volvió a HEALTH_OK"],
|
||||
"rollback": "Reintegrar el controlador anterior",
|
||||
"agent": "claude-code"
|
||||
}`
|
||||
envelope := runJSONCommand(t, root, keyPath, memoryJSON, now.Add(time.Minute),
|
||||
"memory", "create", "--from-json", "-")
|
||||
data := envelopeDataMap(t, envelope)
|
||||
entryID, ok := data["entry_id"].(string)
|
||||
if !ok || entryID == "" {
|
||||
t.Fatalf("missing entry id: %#v", data)
|
||||
}
|
||||
state, err := New(Options{}).BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.ReviewQueue) != 1 || !contains(state.ReviewQueue[0].MissingReviewers, "ana") {
|
||||
t.Fatalf("unexpected review queue: %#v", state.ReviewQueue)
|
||||
}
|
||||
|
||||
runJSONCommand(t, root, keyPath, "", now.Add(2*time.Minute), "review", "mark", entryID, "--note", "Validado")
|
||||
state, err = New(Options{}).BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.ReviewQueue) != 0 {
|
||||
t.Fatalf("reviewed item remained pending: %#v", state.ReviewQueue)
|
||||
}
|
||||
files, err := reviewEventFiles(root)
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("expected one independent review event file, got %v, err=%v", files, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "missing_command", usageText(a.Options.Executable), nil)
|
||||
}
|
||||
command := args[0]
|
||||
if command == "help" || command == "--help" || command == "-h" {
|
||||
return a.printResult("help", usageText(a.Options.Executable))
|
||||
}
|
||||
if command == "version" {
|
||||
return a.printResult("version", map[string]any{"name": "clusterlog", "version": Version})
|
||||
}
|
||||
if command == "schema" {
|
||||
return a.runSchema(args[1:])
|
||||
}
|
||||
|
||||
root, err := a.ResolveRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case "identity":
|
||||
return a.runIdentity(root, args[1:])
|
||||
case "admin":
|
||||
return a.runAdmin(root, args[1:])
|
||||
case "memory":
|
||||
return a.runMemory(root, args[1:])
|
||||
case "review":
|
||||
return a.runReview(root, args[1:])
|
||||
case "task":
|
||||
return a.runTask(root, args[1:])
|
||||
case "project":
|
||||
return a.runProject(root, args[1:])
|
||||
case "content":
|
||||
return a.runContent(root, args[1:])
|
||||
case "sync":
|
||||
return a.runSync(root, args[1:])
|
||||
case "validate":
|
||||
return a.runValidate(root, args[1:])
|
||||
case "verify":
|
||||
return a.runVerify(root, args[1:])
|
||||
default:
|
||||
return NewError(ExitUsage, "unknown_command", "comando desconocido: "+command, usageText(a.Options.Executable))
|
||||
}
|
||||
}
|
||||
|
||||
func usageText(executable string) string {
|
||||
return fmt.Sprintf(`%s [opciones globales] <comando>
|
||||
|
||||
Opciones globales (antes del comando):
|
||||
--root DIR raíz del proyecto
|
||||
--json salida estable para agentes
|
||||
--ssh-key PATH llave pública SSH o ruta base
|
||||
--commit crea un commit Git SSH firmado con sólo los archivos modificados
|
||||
--dry-run valida y muestra el resultado sin escribir
|
||||
|
||||
Comandos:
|
||||
identity
|
||||
admin add | admin list
|
||||
memory create
|
||||
review list | review mark ID
|
||||
task create | start ID | stop ID | adjust ID | status ID | done ID | list
|
||||
project create | list
|
||||
content create
|
||||
sync [--check]
|
||||
validate
|
||||
verify commit [SHA]
|
||||
schema [memory|task|project|content]
|
||||
`, executable)
|
||||
}
|
||||
|
||||
func newFlagSet(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
func wasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func (a *App) runIdentity(root string, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return NewError(ExitUsage, "unexpected_arguments", "identity no acepta argumentos", args)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("identity", identity)
|
||||
}
|
||||
|
||||
func (a *App) runAdmin(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", nil)
|
||||
}
|
||||
if args[0] == "list" {
|
||||
fs := newFlagSet("admin list")
|
||||
activeOnly := fs.Bool("active", false, "muestra sólo administradores activos")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admins := []Admin{}
|
||||
for _, admin := range registry.Admins {
|
||||
if !*activeOnly || admin.Active {
|
||||
admins = append(admins, admin)
|
||||
}
|
||||
}
|
||||
return a.printResult("admin.list", map[string]any{
|
||||
"count": len(admins), "admins": admins, "review_policy": registry.ReviewPolicy,
|
||||
})
|
||||
}
|
||||
if args[0] != "add" {
|
||||
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", args[0])
|
||||
}
|
||||
fs := newFlagSet("admin add")
|
||||
id := fs.String("id", "", "identificador estable")
|
||||
name := fs.String("name", "", "nombre")
|
||||
email := fs.String("email", "", "correo")
|
||||
keyPath := fs.String("key", "", "llave pública")
|
||||
rolesCSV := fs.String("roles", "admin", "roles separados por coma")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
adminID := slugify(*id)
|
||||
if adminID == "" || strings.TrimSpace(*name) == "" || !strings.Contains(*email, "@") {
|
||||
return NewError(ExitUsage, "invalid_admin", "id, name y un email válido son obligatorios", nil)
|
||||
}
|
||||
path := firstNonEmpty(*keyPath, a.Options.SSHKey, os.Getenv("CLUSTERLOG_SSH_KEY"))
|
||||
if path == "" {
|
||||
return NewError(ExitUsage, "missing_ssh_key", "indique --key o --ssh-key para registrar la llave pública", nil)
|
||||
}
|
||||
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
|
||||
if err != nil {
|
||||
return NewError(ExitIdentity, "invalid_ssh_key", err.Error(), path)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var actor Identity
|
||||
if len(registry.Admins) > 0 {
|
||||
actor, err = a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasRole(actor.Admin, "owner") {
|
||||
return NewError(ExitIdentity, "not_authorized", "sólo un administrador con rol owner puede agregar administradores", actor.Admin.ID)
|
||||
}
|
||||
}
|
||||
for _, admin := range registry.Admins {
|
||||
if strings.EqualFold(admin.ID, adminID) || strings.EqualFold(admin.Email, *email) {
|
||||
return NewError(ExitConflict, "admin_exists", "ya existe un administrador con ese id o correo", admin.ID)
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
if key.Fingerprint == fingerprint {
|
||||
return NewError(ExitConflict, "key_exists", "la llave SSH ya pertenece a otro administrador", admin.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
roles := splitCSV(*rolesCSV)
|
||||
if len(registry.Admins) == 0 && !contains(roles, "owner") {
|
||||
roles = append(roles, "owner")
|
||||
}
|
||||
roles = uniqueSorted(roles)
|
||||
newAdmin := Admin{
|
||||
ID: adminID, Name: strings.TrimSpace(*name), Email: strings.TrimSpace(*email),
|
||||
Active: true, Roles: roles,
|
||||
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: canonical, AddedAt: a.Options.Now().UTC().Format(time.RFC3339Nano)}},
|
||||
}
|
||||
registry.Admins = append(registry.Admins, newAdmin)
|
||||
sort.Slice(registry.Admins, func(i, j int) bool { return registry.Admins[i].ID < registry.Admins[j].ID })
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "dry_run": true})
|
||||
}
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commitIdentity := actor
|
||||
if len(registry.Admins) == 1 {
|
||||
commitIdentity = Identity{Admin: newAdmin, Fingerprint: fingerprint, PublicKey: canonical, PublicPath: publicPath, SigningKey: signingPath}
|
||||
}
|
||||
paths := []string{filepath.Join(root, "data", "admins.json"), filepath.Join(root, "config", "allowed_signers")}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, commitIdentity, "admin: add "+newAdmin.ID, paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "proteja data/admins.json y exija commits SSH firmados antes de usarlo en producción")
|
||||
}
|
||||
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "state": state.Stats, "commit": sha}, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runMemory(root string, args []string) error {
|
||||
if len(args) == 0 || args[0] != "create" {
|
||||
return NewError(ExitUsage, "memory_usage", "uso: memory create --title ... [--from-json ARCHIVO|-]", nil)
|
||||
}
|
||||
fs := newFlagSet("memory create")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
risk := fs.String("risk", "", "low|medium|high|critical")
|
||||
ticket := fs.String("ticket", "", "ticket relacionado")
|
||||
review := fs.Bool("review", true, "requiere revisión")
|
||||
reviewPolicy := fs.String("review-policy", "", "all-active|all-active-except-author")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
duration := fs.String("duration", "", "minutos, 90m, 2h")
|
||||
agent := fs.String("agent", "", "agente que preparó el texto")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input MemoryInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "risk") {
|
||||
input.Risk = *risk
|
||||
}
|
||||
if wasSet(fs, "ticket") {
|
||||
input.Ticket = *ticket
|
||||
}
|
||||
if wasSet(fs, "review") {
|
||||
input.ReviewRequired = review
|
||||
}
|
||||
if wasSet(fs, "review-policy") {
|
||||
input.ReviewPolicy = *reviewPolicy
|
||||
}
|
||||
if wasSet(fs, "agent") {
|
||||
input.Agent = *agent
|
||||
}
|
||||
if *duration != "" {
|
||||
minutes, err := parseDurationMinutes(*duration)
|
||||
if err != nil {
|
||||
return NewError(ExitUsage, "invalid_duration", err.Error(), nil)
|
||||
}
|
||||
input.DurationMinutes = minutes
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createMemory(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("memory.create", result, warnings...)
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdPath := filepath.FromSlash(result["path"].(string))
|
||||
paths := []string{createdPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("memory(%s): %s", result["entry_id"], input.Title), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
result["pending_reviews"] = state.Stats.PendingReviews
|
||||
return a.printResult("memory.create", result, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runReview(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID [--note TEXTO]", nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
fs := newFlagSet("review list")
|
||||
mine := fs.Bool("mine", false, "sólo pendientes del administrador actual")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queue := state.ReviewQueue
|
||||
if *mine {
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := []ReviewQueueItem{}
|
||||
for _, item := range queue {
|
||||
if contains(item.MissingReviewers, identity.Admin.ID) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
queue = filtered
|
||||
}
|
||||
return a.printResult("review.list", map[string]any{"count": len(queue), "items": queue})
|
||||
case "mark":
|
||||
if len(args) < 2 {
|
||||
return NewError(ExitUsage, "missing_entry_id", "indique el ID de la memoria", nil)
|
||||
}
|
||||
entryID := args[1]
|
||||
fs := newFlagSet("review mark")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[2:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memory, _, err := findMemory(root, entryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !memory.ReviewRequired {
|
||||
return NewError(ExitValidation, "review_not_required", "la memoria no requiere revisión", memory.EntryID)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if memory.ReviewerSnapshot {
|
||||
if !contains(memory.Reviewers, identity.Admin.ID) {
|
||||
return NewError(ExitValidation, "review_not_required_for_admin", "el administrador no forma parte del conjunto de revisores capturado para esta memoria", map[string]any{
|
||||
"admin_id": identity.Admin.ID, "reviewers": memory.Reviewers,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
policy := firstNonEmpty(memory.ReviewPolicy, registry.ReviewPolicy.Default)
|
||||
if (policy == "all-active-except-author" || (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)) && identity.Admin.ID == memory.AuthorID {
|
||||
return NewError(ExitValidation, "review_not_required_for_author", "la política excluye al autor de la revisión", identity.Admin.ID)
|
||||
}
|
||||
}
|
||||
log, err := loadReviewLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range log.Events {
|
||||
if normalizeID(event.EntryID) == memory.EntryID && event.AdminID == identity.Admin.ID && event.ContentSHA256 == memory.ContentSHA256 {
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("review.mark", map[string]any{"entry_id": memory.EntryID, "admin_id": identity.Admin.ID, "already_reviewed": true, "pending_reviews": state.Stats.PendingReviews})
|
||||
}
|
||||
}
|
||||
eventID, err := generateID("REV", a.Options.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := ReviewEvent{ID: eventID, EntryID: memory.EntryID, AdminID: identity.Admin.ID, Fingerprint: identity.Fingerprint, ReviewedAt: a.Options.Now().UTC().Format(time.RFC3339Nano), ContentSHA256: memory.ContentSHA256, Note: strings.TrimSpace(*note)}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("review.mark", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
eventPath, err := writeReviewEvent(root, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := []string{eventPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("review(%s): %s", memory.EntryID, identity.Admin.ID), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("review.mark", map[string]any{"event": event, "pending_reviews": state.Stats.PendingReviews, "commit": sha})
|
||||
default:
|
||||
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID", args[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (a *App) runProject(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "project_usage", "uso: project create | list", nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
fs := newFlagSet("project create")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
objective := fs.String("objective", "", "objetivo")
|
||||
owners := fs.String("owners", "", "administradores separados por coma")
|
||||
targetDate := fs.String("target-date", "", "fecha YYYY-MM-DD")
|
||||
status := fs.String("status", "", "planned|active|blocked|done|cancelled")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
scope := fs.String("scope", "", "elementos de alcance separados por coma")
|
||||
outOfScope := fs.String("out-of-scope", "", "elementos fuera de alcance separados por coma")
|
||||
milestones := fs.String("milestones", "", "hitos separados por coma")
|
||||
success := fs.String("success", "", "criterios de éxito separados por coma")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input ProjectInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "objective") {
|
||||
input.Objective = *objective
|
||||
}
|
||||
if wasSet(fs, "owners") {
|
||||
input.Owners = splitCSV(*owners)
|
||||
}
|
||||
if wasSet(fs, "target-date") {
|
||||
input.TargetDate = *targetDate
|
||||
}
|
||||
if wasSet(fs, "status") {
|
||||
input.Status = *status
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "scope") {
|
||||
input.Scope = splitCSV(*scope)
|
||||
}
|
||||
if wasSet(fs, "out-of-scope") {
|
||||
input.OutOfScope = splitCSV(*outOfScope)
|
||||
}
|
||||
if wasSet(fs, "milestones") {
|
||||
input.Milestones = splitCSV(*milestones)
|
||||
}
|
||||
if wasSet(fs, "success") {
|
||||
input.Success = splitCSV(*success)
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createProject(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("project.create", result, warnings...)
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := []string{filepath.FromSlash(result["path"].(string))}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("project(%s): %s", result["project_id"], input.Title), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
result["active_projects"] = state.Stats.ActiveProjects
|
||||
return a.printResult("project.create", result, warnings...)
|
||||
case "list":
|
||||
fs := newFlagSet("project list")
|
||||
status := fs.String("status", "", "filtrar por estado")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := []ProjectState{}
|
||||
for _, item := range state.Projects {
|
||||
if *status == "" || item.Status == *status {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return a.printResult("project.list", map[string]any{"count": len(items), "items": items})
|
||||
default:
|
||||
return NewError(ExitUsage, "project_usage", "uso: project create | list", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runContent(root string, args []string) error {
|
||||
if len(args) == 0 || args[0] != "create" {
|
||||
return NewError(ExitUsage, "content_usage", "uso: content create --section documentacion|manuales --title ... --body-file ...", nil)
|
||||
}
|
||||
fs := newFlagSet("content create")
|
||||
section := fs.String("section", "", "documentacion|manuales")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
weight := fs.Int("weight", 0, "orden opcional")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input ContentInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "section") {
|
||||
input.Section = *section
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "weight") {
|
||||
input.Weight = *weight
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createContent(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("content.create", result, warnings...)
|
||||
}
|
||||
createdPath := filepath.FromSlash(result["path"].(string))
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("%s(%s): %s", input.Section, result["content_id"], input.Title), []string{createdPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
return a.printResult("content.create", result, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runSync(root string, args []string) error {
|
||||
fs := newFlagSet("sync")
|
||||
check := fs.Bool("check", false, "no escribe; falla si el estado generado está desactualizado")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, changed, path, err := a.Sync(root, *check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("sync", map[string]any{
|
||||
"path": filepath.ToSlash(path), "changed": changed, "check": *check,
|
||||
"source_sha256": state.SourceSHA256, "stats": state.Stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) runVerify(root string, args []string) error {
|
||||
if len(args) == 0 || args[0] != "commit" {
|
||||
return NewError(ExitUsage, "verify_usage", "uso: verify commit [SHA]", nil)
|
||||
}
|
||||
sha := "HEAD"
|
||||
if len(args) > 1 {
|
||||
sha = args[1]
|
||||
}
|
||||
if len(args) > 2 {
|
||||
return NewError(ExitUsage, "unexpected_arguments", "uso: verify commit [SHA]", args[2:])
|
||||
}
|
||||
if err := verifyCommit(root, sha); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("verify.commit", map[string]any{"commit": sha, "valid": true})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) runTask(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
return a.runTaskCreate(root, args[1:])
|
||||
case "start":
|
||||
return a.runTaskStart(root, args[1:])
|
||||
case "stop":
|
||||
return a.runTaskStop(root, args[1:])
|
||||
case "adjust":
|
||||
return a.runTaskAdjust(root, args[1:])
|
||||
case "status":
|
||||
return a.runTaskStatus(root, args[1:])
|
||||
case "done":
|
||||
return a.runTaskDone(root, args[1:])
|
||||
case "list":
|
||||
return a.runTaskList(root, args[1:])
|
||||
default:
|
||||
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runTaskCreate(root string, args []string) error {
|
||||
fs := newFlagSet("task create")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
projectID := fs.String("project", "", "ID de proyecto")
|
||||
priority := fs.String("priority", "", "low|medium|high|urgent")
|
||||
assignees := fs.String("assignees", "", "administradores separados por coma")
|
||||
estimate := fs.String("estimate", "", "minutos, 90m, 2h")
|
||||
due := fs.String("due", "", "fecha YYYY-MM-DD")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
objective := fs.String("objective", "", "objetivo")
|
||||
acceptance := fs.String("acceptance", "", "criterios separados por coma")
|
||||
notes := fs.String("notes", "", "notas")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input TaskInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "project") {
|
||||
input.ProjectID = *projectID
|
||||
}
|
||||
if wasSet(fs, "priority") {
|
||||
input.Priority = *priority
|
||||
}
|
||||
if wasSet(fs, "assignees") {
|
||||
input.Assignees = splitCSV(*assignees)
|
||||
}
|
||||
if wasSet(fs, "due") {
|
||||
input.Due = *due
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "objective") {
|
||||
input.Objective = *objective
|
||||
}
|
||||
if wasSet(fs, "acceptance") {
|
||||
input.Acceptance = splitCSV(*acceptance)
|
||||
}
|
||||
if wasSet(fs, "notes") {
|
||||
input.Notes = *notes
|
||||
}
|
||||
if *estimate != "" {
|
||||
minutes, err := parseDurationMinutes(*estimate)
|
||||
if err != nil {
|
||||
return NewError(ExitUsage, "invalid_estimate", err.Error(), nil)
|
||||
}
|
||||
input.EstimateMinutes = minutes
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createTask(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.create", result, warnings...)
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdPath := filepath.FromSlash(result["path"].(string))
|
||||
paths := []string{createdPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", result["task_id"], input.Title), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
result["open_tasks"] = state.Stats.OpenTasks
|
||||
return a.printResult("task.create", result, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runTaskStart(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task start ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task start")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentStatus := task.Status
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID == task.TaskID {
|
||||
currentStatus = current.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
if statusIsClosed(currentStatus) {
|
||||
return NewError(ExitConflict, "task_closed", "no se puede iniciar una tarea cerrada", map[string]any{
|
||||
"task_id": task.TaskID, "status": currentStatus,
|
||||
})
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active, ok := taskActiveStart(log, identity.Admin.ID); ok {
|
||||
if normalizeID(active.TaskID) == task.TaskID {
|
||||
return a.printResult("task.start", map[string]any{
|
||||
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
|
||||
"already_running": true, "started_at": active.At,
|
||||
})
|
||||
}
|
||||
return NewError(ExitConflict, "timer_already_running", "el administrador ya tiene otra tarea activa", map[string]any{
|
||||
"active_task_id": active.TaskID,
|
||||
"started_at": active.At,
|
||||
"hint": "ejecute task stop para la tarea activa",
|
||||
})
|
||||
}
|
||||
eventID, err := generateID("EVT", a.Options.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "start", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: a.Options.Now().UTC().Format(time.RFC3339Nano), Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.start", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "start")
|
||||
}
|
||||
|
||||
func (a *App) runTaskStop(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task stop ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task stop")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID)
|
||||
if !running {
|
||||
return NewError(ExitConflict, "timer_not_running", "no hay un temporizador activo para esta tarea y administrador", map[string]any{
|
||||
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
|
||||
})
|
||||
}
|
||||
startedAt, err := time.Parse(time.RFC3339, started.At)
|
||||
if err != nil {
|
||||
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
if now.Before(startedAt) {
|
||||
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
|
||||
}
|
||||
elapsed := now.Sub(startedAt)
|
||||
minutes := int((elapsed + time.Minute - 1) / time.Minute)
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.stop", map[string]any{"event": event, "elapsed": formatMinutes(minutes), "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "stop")
|
||||
}
|
||||
|
||||
func (a *App) runTaskAdjust(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task adjust ID --minutes N [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task adjust")
|
||||
minutes := fs.Int("minutes", 0, "minutos positivos o negativos")
|
||||
note := fs.String("note", "", "justificación del ajuste")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
if !wasSet(fs, "minutes") || *minutes == 0 {
|
||||
return NewError(ExitUsage, "invalid_adjustment", "--minutes debe ser distinto de cero", nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID == task.TaskID && current.ActualMinutes+*minutes < 0 {
|
||||
return NewError(ExitValidation, "negative_actual_time", "el ajuste dejaría el tiempo real por debajo de cero", map[string]any{
|
||||
"task_id": task.TaskID, "actual_minutes": current.ActualMinutes, "adjustment": *minutes,
|
||||
})
|
||||
}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "adjust", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: *minutes, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.adjust", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "adjust")
|
||||
}
|
||||
|
||||
func (a *App) runTaskStatus(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task status ID --status backlog|todo|in_progress|blocked|cancelled [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task status")
|
||||
status := fs.String("status", "", "backlog|todo|in_progress|blocked|cancelled")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
newStatus := strings.ToLower(strings.TrimSpace(*status))
|
||||
allowed := map[string]bool{"backlog": true, "todo": true, "in_progress": true, "blocked": true, "cancelled": true}
|
||||
if !allowed[newStatus] {
|
||||
return NewError(ExitUsage, "invalid_task_status", "use backlog, todo, in_progress, blocked o cancelled; para completar use task done", newStatus)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID != task.TaskID {
|
||||
continue
|
||||
}
|
||||
if current.Status == newStatus {
|
||||
return a.printResult("task.status", map[string]any{"task_id": task.TaskID, "status": newStatus, "already_set": true})
|
||||
}
|
||||
if statusIsClosed(newStatus) && len(current.RunningBy) > 0 {
|
||||
return NewError(ExitConflict, "task_has_running_timers", "detenga todos los temporizadores antes de cerrar la tarea", map[string]any{
|
||||
"task_id": task.TaskID, "running_by": current.RunningBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: newStatus, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.status", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "status")
|
||||
}
|
||||
|
||||
func (a *App) runTaskDone(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task done ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task done")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stateBefore, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range stateBefore.Tasks {
|
||||
if current.TaskID != task.TaskID {
|
||||
continue
|
||||
}
|
||||
if current.Status == "done" {
|
||||
return a.printResult("task.done", map[string]any{"task_id": task.TaskID, "already_done": true})
|
||||
}
|
||||
otherRunners := []string{}
|
||||
for _, adminID := range current.RunningBy {
|
||||
if adminID != identity.Admin.ID {
|
||||
otherRunners = append(otherRunners, adminID)
|
||||
}
|
||||
}
|
||||
if len(otherRunners) > 0 {
|
||||
return NewError(ExitConflict, "task_has_other_running_timers", "otros administradores aún registran tiempo en esta tarea", map[string]any{
|
||||
"task_id": task.TaskID, "running_by": otherRunners,
|
||||
})
|
||||
}
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
newEvents := []TaskEvent{}
|
||||
if started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID); running {
|
||||
startedAt, parseErr := time.Parse(time.RFC3339, started.At)
|
||||
if parseErr != nil {
|
||||
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
|
||||
}
|
||||
if now.Before(startedAt) {
|
||||
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
|
||||
}
|
||||
minutes := int((now.Sub(startedAt) + time.Minute - 1) / time.Minute)
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
id, idErr := generateID("EVT", now)
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
newEvents = append(newEvents, TaskEvent{
|
||||
ID: id, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes,
|
||||
Note: "temporizador cerrado al completar la tarea",
|
||||
})
|
||||
}
|
||||
id, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statusEvent := TaskEvent{
|
||||
ID: id, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: "done", Note: strings.TrimSpace(*note),
|
||||
}
|
||||
newEvents = append(newEvents, statusEvent)
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.done", map[string]any{"events": newEvents, "dry_run": true})
|
||||
}
|
||||
eventPaths, err := writeTaskEvents(root, newEvents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := append([]string(nil), eventPaths...)
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): done", task.TaskID), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var final TaskState
|
||||
for _, item := range state.Tasks {
|
||||
if item.TaskID == task.TaskID {
|
||||
final = item
|
||||
break
|
||||
}
|
||||
}
|
||||
return a.printResult("task.done", map[string]any{"task": final, "events": newEvents, "commit": sha})
|
||||
}
|
||||
|
||||
func (a *App) runTaskList(root string, args []string) error {
|
||||
fs := newFlagSet("task list")
|
||||
status := fs.String("status", "", "filtrar por estado")
|
||||
mine := fs.Bool("mine", false, "sólo tareas asignadas o ejecutadas por el administrador actual")
|
||||
projectID := fs.String("project", "", "filtrar por proyecto")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var adminID string
|
||||
if *mine {
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminID = identity.Admin.ID
|
||||
}
|
||||
items := []TaskState{}
|
||||
for _, item := range state.Tasks {
|
||||
if *status != "" && item.Status != strings.ToLower(*status) {
|
||||
continue
|
||||
}
|
||||
if *projectID != "" && item.ProjectID != normalizeID(*projectID) {
|
||||
continue
|
||||
}
|
||||
if *mine && !contains(item.Assignees, adminID) && !contains(item.RunningBy, adminID) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return a.printResult("task.list", map[string]any{"count": len(items), "items": items})
|
||||
}
|
||||
|
||||
func (a *App) persistTaskEvent(root string, identity Identity, event TaskEvent, action string) error {
|
||||
eventPaths, err := writeTaskEvents(root, []TaskEvent{event})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := append([]string(nil), eventPaths...)
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", event.TaskID, action), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var task TaskState
|
||||
for _, item := range state.Tasks {
|
||||
if item.TaskID == normalizeID(event.TaskID) {
|
||||
task = item
|
||||
break
|
||||
}
|
||||
}
|
||||
return a.printResult("task."+action, map[string]any{"event": event, "task": task, "commit": sha})
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemoryInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Systems []string `json:"systems"`
|
||||
Risk string `json:"risk"`
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
ReviewRequired *bool `json:"review_required,omitempty"`
|
||||
ReviewPolicy string `json:"review_policy,omitempty"`
|
||||
BodyMarkdown string `json:"body_markdown,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Changes []string `json:"changes,omitempty"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
Validation []string `json:"validation,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
DurationMinutes int `json:"duration_minutes,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
type TaskInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Assignees []string `json:"assignees,omitempty"`
|
||||
EstimateMinutes int `json:"estimate_minutes,omitempty"`
|
||||
Due string `json:"due,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Systems []string `json:"systems,omitempty"`
|
||||
BodyMarkdown string `json:"body_markdown,omitempty"`
|
||||
Objective string `json:"objective,omitempty"`
|
||||
Acceptance []string `json:"acceptance_criteria,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Objective string `json:"objective"`
|
||||
Owners []string `json:"owners,omitempty"`
|
||||
TargetDate string `json:"target_date,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Systems []string `json:"systems,omitempty"`
|
||||
BodyMarkdown string `json:"body_markdown,omitempty"`
|
||||
Scope []string `json:"scope,omitempty"`
|
||||
OutOfScope []string `json:"out_of_scope,omitempty"`
|
||||
Milestones []string `json:"milestones,omitempty"`
|
||||
Success []string `json:"success_criteria,omitempty"`
|
||||
}
|
||||
|
||||
type ContentInput struct {
|
||||
Section string `json:"section"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Systems []string `json:"systems,omitempty"`
|
||||
BodyMarkdown string `json:"body_markdown"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
func decodeJSONInput(path string, stdinReader io.Reader, dst any) error {
|
||||
data, err := readAll(path, stdinReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return NewError(ExitUsage, "invalid_json", "el documento de entrada no es JSON válido o contiene campos desconocidos", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderMemoryBody(input MemoryInput) string {
|
||||
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
||||
return normalizeMarkdownBody(input.BodyMarkdown)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Contexto\n\n")
|
||||
b.WriteString(firstNonEmpty(input.Context, "_No especificado._"))
|
||||
b.WriteString("\n\n## Cambios realizados\n\n")
|
||||
b.WriteString(bullets(input.Changes))
|
||||
b.WriteString("\n## Comandos relevantes (secretos redactados)\n\n")
|
||||
b.WriteString(codeBlock(input.Commands))
|
||||
b.WriteString("\n## Validación\n\n")
|
||||
b.WriteString(bullets(input.Validation))
|
||||
b.WriteString("\n## Riesgo y reversión\n\n")
|
||||
if input.Rollback == "" {
|
||||
b.WriteString("_No se documentó un procedimiento de reversión._\n")
|
||||
} else {
|
||||
b.WriteString(input.Rollback + "\n")
|
||||
}
|
||||
if input.Ticket != "" {
|
||||
b.WriteString("\n## Referencias\n\n- Ticket: `" + input.Ticket + "`\n")
|
||||
}
|
||||
return normalizeMarkdownBody(b.String())
|
||||
}
|
||||
|
||||
func renderTaskBody(input TaskInput) string {
|
||||
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
||||
return normalizeMarkdownBody(input.BodyMarkdown)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Objetivo\n\n")
|
||||
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
|
||||
b.WriteString("\n\n## Criterios de aceptación\n\n")
|
||||
b.WriteString(bullets(input.Acceptance))
|
||||
if input.Notes != "" {
|
||||
b.WriteString("\n## Notas\n\n" + input.Notes + "\n")
|
||||
}
|
||||
return normalizeMarkdownBody(b.String())
|
||||
}
|
||||
|
||||
func renderProjectBody(input ProjectInput) string {
|
||||
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
||||
return normalizeMarkdownBody(input.BodyMarkdown)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Objetivo\n\n")
|
||||
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
|
||||
b.WriteString("\n\n## Alcance\n\n")
|
||||
b.WriteString(bullets(input.Scope))
|
||||
b.WriteString("\n## Fuera de alcance\n\n")
|
||||
b.WriteString(bullets(input.OutOfScope))
|
||||
b.WriteString("\n## Hitos\n\n")
|
||||
b.WriteString(bullets(input.Milestones))
|
||||
b.WriteString("\n## Criterios de éxito\n\n")
|
||||
b.WriteString(bullets(input.Success))
|
||||
return normalizeMarkdownBody(b.String())
|
||||
}
|
||||
|
||||
func (a *App) createMemory(root string, identity Identity, input MemoryInput) (map[string]any, []string, error) {
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
input.Description = firstNonEmpty(input.Description, input.Title)
|
||||
input.Risk = strings.ToLower(firstNonEmpty(input.Risk, "low"))
|
||||
validRisk := map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
|
||||
if input.Title == "" {
|
||||
return nil, nil, NewError(ExitUsage, "missing_title", "--title o title en JSON es obligatorio", nil)
|
||||
}
|
||||
if !validRisk[input.Risk] {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_risk", "risk debe ser low, medium, high o critical", input.Risk)
|
||||
}
|
||||
reviewRequired := true
|
||||
if input.ReviewRequired != nil {
|
||||
reviewRequired = *input.ReviewRequired
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
input.ReviewPolicy = firstNonEmpty(input.ReviewPolicy, registry.ReviewPolicy.Default, "all-active")
|
||||
if input.ReviewPolicy != "all-active" && input.ReviewPolicy != "all-active-except-author" {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_review_policy", "review_policy debe ser all-active o all-active-except-author", input.ReviewPolicy)
|
||||
}
|
||||
reviewers := []string{}
|
||||
if reviewRequired {
|
||||
reviewers = requiredReviewers(registry, input.ReviewPolicy, identity.Admin.ID)
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
id, err := generateID("MEM", now)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slug := slugify(id + "-" + input.Title)
|
||||
body := renderMemoryBody(input)
|
||||
front := strings.Builder{}
|
||||
front.WriteString("+++\n")
|
||||
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
||||
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
||||
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
||||
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
||||
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
||||
front.WriteString("template = \"memory.html\"\n\n")
|
||||
front.WriteString("[taxonomies]\n")
|
||||
front.WriteString("categories = [\"memorias\"]\n")
|
||||
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
||||
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
||||
front.WriteString("[extra]\n")
|
||||
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
||||
front.WriteString("kind = \"memory\"\n")
|
||||
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
|
||||
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
||||
fmt.Fprintf(&front, "risk = %s\n", tomlString(input.Risk))
|
||||
fmt.Fprintf(&front, "ticket = %s\n", tomlString(input.Ticket))
|
||||
fmt.Fprintf(&front, "review_required = %t\n", reviewRequired)
|
||||
fmt.Fprintf(&front, "review_policy = %s\n", tomlString(input.ReviewPolicy))
|
||||
front.WriteString("reviewer_snapshot = true\n")
|
||||
fmt.Fprintf(&front, "reviewers = %s\n", tomlStringArray(reviewers))
|
||||
fmt.Fprintf(&front, "started_at = %s\n", tomlString(input.StartedAt))
|
||||
fmt.Fprintf(&front, "finished_at = %s\n", tomlString(input.FinishedAt))
|
||||
fmt.Fprintf(&front, "duration_minutes = %d\n", input.DurationMinutes)
|
||||
fmt.Fprintf(&front, "agent = %s\n", tomlString(input.Agent))
|
||||
front.WriteString("+++\n\n")
|
||||
content := front.String() + body
|
||||
if findings := scanSecrets(content); len(findings) > 0 {
|
||||
return nil, nil, NewError(ExitValidation, "possible_secret", "la memoria parece contener secretos; redacte antes de registrarla", findings)
|
||||
}
|
||||
filename := now.Format("2006-01-02") + "-" + slug + ".md"
|
||||
path := filepath.Join(root, "content", "memorias", filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil, nil, NewError(ExitConflict, "file_exists", "ya existe el archivo de memoria", path)
|
||||
}
|
||||
if !a.Options.DryRun {
|
||||
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "la atribución local usa la huella SSH, pero la prueba criptográfica exige un commit firmado; repita con --commit o firme el commit manualmente")
|
||||
}
|
||||
return map[string]any{
|
||||
"entry_id": id, "path": filepath.ToSlash(path), "slug": slug,
|
||||
"review_required": reviewRequired, "reviewers": reviewers, "content_sha256": bytesSHA256([]byte(content)),
|
||||
"dry_run": a.Options.DryRun,
|
||||
}, warnings, nil
|
||||
}
|
||||
|
||||
func (a *App) createTask(root string, identity Identity, input TaskInput) (map[string]any, []string, error) {
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
input.Description = firstNonEmpty(input.Description, input.Title)
|
||||
input.Priority = strings.ToLower(firstNonEmpty(input.Priority, "medium"))
|
||||
if input.Title == "" {
|
||||
return nil, nil, NewError(ExitUsage, "missing_title", "el título de la tarea es obligatorio", nil)
|
||||
}
|
||||
if !map[string]bool{"low": true, "medium": true, "high": true, "urgent": true}[input.Priority] {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_priority", "priority debe ser low, medium, high o urgent", input.Priority)
|
||||
}
|
||||
if err := validateDate(input.Due); err != nil {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_due", err.Error(), nil)
|
||||
}
|
||||
if err := ensureProjectExists(root, input.ProjectID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, assignee := range input.Assignees {
|
||||
admin, ok := adminByID(registry, assignee)
|
||||
if !ok || !admin.Active {
|
||||
return nil, nil, NewError(ExitValidation, "invalid_assignee", "la tarea contiene un responsable inexistente o inactivo", assignee)
|
||||
}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
id, err := generateID("TSK", now)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slug := slugify(id + "-" + input.Title)
|
||||
body := renderTaskBody(input)
|
||||
var front strings.Builder
|
||||
front.WriteString("+++\n")
|
||||
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
||||
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
||||
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
||||
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
||||
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
||||
front.WriteString("template = \"task.html\"\n\n")
|
||||
front.WriteString("[taxonomies]\n")
|
||||
front.WriteString("categories = [\"tareas\"]\n")
|
||||
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
||||
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
||||
front.WriteString("[extra]\n")
|
||||
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
||||
front.WriteString("kind = \"task\"\nstatus = \"todo\"\n")
|
||||
fmt.Fprintf(&front, "priority = %s\n", tomlString(input.Priority))
|
||||
fmt.Fprintf(&front, "project_id = %s\n", tomlString(normalizeID(input.ProjectID)))
|
||||
fmt.Fprintf(&front, "assignees = %s\n", tomlStringArray(uniqueSorted(input.Assignees)))
|
||||
fmt.Fprintf(&front, "estimate_minutes = %d\n", input.EstimateMinutes)
|
||||
fmt.Fprintf(&front, "due = %s\n", tomlString(input.Due))
|
||||
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
|
||||
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
||||
front.WriteString("+++\n\n")
|
||||
content := front.String() + body
|
||||
if findings := scanSecrets(content); len(findings) > 0 {
|
||||
return nil, nil, NewError(ExitValidation, "possible_secret", "la tarea parece contener secretos; redacte antes de registrarla", findings)
|
||||
}
|
||||
path := filepath.Join(root, "content", "tareas", now.Format("2006-01-02")+"-"+slug+".md")
|
||||
if !a.Options.DryRun {
|
||||
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
||||
}
|
||||
return map[string]any{"task_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
||||
}
|
||||
|
||||
func (a *App) createProject(root string, identity Identity, input ProjectInput) (map[string]any, []string, error) {
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
input.Description = firstNonEmpty(input.Description, input.Objective, input.Title)
|
||||
input.Status = strings.ToLower(firstNonEmpty(input.Status, "planned"))
|
||||
if input.Title == "" || strings.TrimSpace(input.Objective) == "" {
|
||||
return nil, nil, NewError(ExitUsage, "missing_project_fields", "title y objective son obligatorios", nil)
|
||||
}
|
||||
if !map[string]bool{"planned": true, "active": true, "blocked": true, "done": true, "cancelled": true, "archived": true}[input.Status] {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_project_status", "status debe ser planned, active, blocked, done, cancelled o archived", input.Status)
|
||||
}
|
||||
if err := validateDate(input.TargetDate); err != nil {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_target_date", err.Error(), nil)
|
||||
}
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, owner := range input.Owners {
|
||||
admin, ok := adminByID(registry, owner)
|
||||
if !ok || !admin.Active {
|
||||
return nil, nil, NewError(ExitValidation, "invalid_owner", "el proyecto contiene un responsable inexistente o inactivo", owner)
|
||||
}
|
||||
}
|
||||
if len(input.Owners) == 0 {
|
||||
input.Owners = []string{identity.Admin.ID}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
id, err := generateID("PRJ", now)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slug := slugify(id + "-" + input.Title)
|
||||
body := renderProjectBody(input)
|
||||
var front strings.Builder
|
||||
front.WriteString("+++\n")
|
||||
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
||||
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
||||
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
||||
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
||||
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
||||
front.WriteString("template = \"project.html\"\n\n")
|
||||
front.WriteString("[taxonomies]\n")
|
||||
front.WriteString("categories = [\"proyectos\"]\n")
|
||||
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
||||
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
||||
front.WriteString("[extra]\n")
|
||||
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
||||
front.WriteString("kind = \"project\"\n")
|
||||
fmt.Fprintf(&front, "status = %s\n", tomlString(input.Status))
|
||||
fmt.Fprintf(&front, "owners = %s\n", tomlStringArray(uniqueSorted(input.Owners)))
|
||||
fmt.Fprintf(&front, "target_date = %s\n", tomlString(input.TargetDate))
|
||||
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
|
||||
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
||||
front.WriteString("+++\n\n")
|
||||
content := front.String() + body
|
||||
if findings := scanSecrets(content); len(findings) > 0 {
|
||||
return nil, nil, NewError(ExitValidation, "possible_secret", "el proyecto parece contener secretos; redacte antes de registrarlo", findings)
|
||||
}
|
||||
path := filepath.Join(root, "content", "proyectos", now.Format("2006-01-02")+"-"+slug+".md")
|
||||
if !a.Options.DryRun {
|
||||
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
||||
}
|
||||
return map[string]any{"project_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
||||
}
|
||||
|
||||
func (a *App) createContent(root string, identity Identity, input ContentInput) (map[string]any, []string, error) {
|
||||
input.Section = strings.ToLower(strings.TrimSpace(input.Section))
|
||||
if input.Section != "documentacion" && input.Section != "manuales" {
|
||||
return nil, nil, NewError(ExitUsage, "invalid_section", "section debe ser documentacion o manuales", input.Section)
|
||||
}
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
input.Description = firstNonEmpty(input.Description, input.Title)
|
||||
if input.Title == "" || strings.TrimSpace(input.BodyMarkdown) == "" {
|
||||
return nil, nil, NewError(ExitUsage, "missing_content_fields", "title y body_markdown son obligatorios", nil)
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
idPrefix := "DOC"
|
||||
if input.Section == "manuales" {
|
||||
idPrefix = "RUN"
|
||||
}
|
||||
id, err := generateID(idPrefix, now)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slug := slugify(id + "-" + input.Title)
|
||||
var front strings.Builder
|
||||
front.WriteString("+++\n")
|
||||
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
||||
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
||||
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
||||
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
||||
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
||||
front.WriteString("template = \"page.html\"\n")
|
||||
if input.Weight != 0 {
|
||||
fmt.Fprintf(&front, "weight = %d\n", input.Weight)
|
||||
}
|
||||
front.WriteString("\n[taxonomies]\n")
|
||||
fmt.Fprintf(&front, "categories = %s\n", tomlStringArray([]string{input.Section}))
|
||||
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
||||
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
||||
front.WriteString("[extra]\n")
|
||||
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
||||
fmt.Fprintf(&front, "kind = %s\n", tomlString(input.Section))
|
||||
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
|
||||
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
||||
front.WriteString("+++\n\n")
|
||||
content := front.String() + normalizeMarkdownBody(input.BodyMarkdown)
|
||||
if findings := scanSecrets(content); len(findings) > 0 {
|
||||
return nil, nil, NewError(ExitValidation, "possible_secret", "el contenido parece incluir secretos; redacte antes de registrarlo", findings)
|
||||
}
|
||||
path := filepath.Join(root, "content", input.Section, now.Format("2006-01-02")+"-"+slug+".md")
|
||||
if !a.Options.DryRun {
|
||||
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
warnings := []string{}
|
||||
if !a.Options.Commit {
|
||||
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
||||
}
|
||||
return map[string]any{"content_id": id, "section": input.Section, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
reviewEventsDirName = "review-events"
|
||||
taskEventsDirName = "task-events"
|
||||
)
|
||||
|
||||
func listJSONFiles(dir string) ([]string, error) {
|
||||
files := []string{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return files, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func reviewEventFiles(root string) ([]string, error) {
|
||||
return listJSONFiles(filepath.Join(root, "data", reviewEventsDirName))
|
||||
}
|
||||
|
||||
func taskEventFiles(root string) ([]string, error) {
|
||||
return listJSONFiles(filepath.Join(root, "data", taskEventsDirName))
|
||||
}
|
||||
|
||||
func eventFilePath(root, dirName, id string) string {
|
||||
return filepath.Join(root, "data", dirName, strings.ToLower(id)+".json")
|
||||
}
|
||||
|
||||
func writeEventFile(path string, value any) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return NewError(ExitConflict, "event_exists", "ya existe un evento con el mismo identificador", filepath.ToSlash(path))
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return writeJSONAtomic(path, value)
|
||||
}
|
||||
|
||||
func writeReviewEvent(root string, event ReviewEvent) (string, error) {
|
||||
path := eventFilePath(root, reviewEventsDirName, event.ID)
|
||||
return path, writeEventFile(path, event)
|
||||
}
|
||||
|
||||
func writeTaskEvents(root string, events []TaskEvent) ([]string, error) {
|
||||
paths := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
path := eventFilePath(root, taskEventsDirName, event.ID)
|
||||
if err := writeEventFile(path, event); err != nil {
|
||||
return paths, err
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func parseEventTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func sortReviewEvents(events []ReviewEvent) {
|
||||
sort.SliceStable(events, func(i, j int) bool {
|
||||
ti := parseEventTime(events[i].ReviewedAt)
|
||||
tj := parseEventTime(events[j].ReviewedAt)
|
||||
if ti.Equal(tj) {
|
||||
return events[i].ID < events[j].ID
|
||||
}
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
|
||||
func sortTaskEvents(events []TaskEvent) {
|
||||
typeOrder := map[string]int{"start": 0, "stop": 1, "adjust": 2, "status": 3}
|
||||
sort.SliceStable(events, func(i, j int) bool {
|
||||
ti := parseEventTime(events[i].At)
|
||||
tj := parseEventTime(events[j].At)
|
||||
if ti.Equal(tj) {
|
||||
oi, iOK := typeOrder[events[i].Type]
|
||||
oj, jOK := typeOrder[events[j].Type]
|
||||
if !iOK {
|
||||
oi = 9
|
||||
}
|
||||
if !jOK {
|
||||
oj = 9
|
||||
}
|
||||
if oi == oj {
|
||||
return events[i].ID < events[j].ID
|
||||
}
|
||||
return oi < oj
|
||||
}
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func gitOutput(root string, args ...string) (string, error) {
|
||||
full := append([]string{"-C", root}, args...)
|
||||
cmd := exec.Command("git", full...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func isGitRepo(root string) bool {
|
||||
output, err := gitOutput(root, "rev-parse", "--is-inside-work-tree")
|
||||
return err == nil && output == "true"
|
||||
}
|
||||
|
||||
func (a *App) commitPaths(root string, identity Identity, message string, paths []string) (string, error) {
|
||||
if !a.Options.Commit {
|
||||
return "", nil
|
||||
}
|
||||
if !isGitRepo(root) {
|
||||
return "", NewError(ExitGit, "not_git_repo", "--commit requiere que el proyecto sea un repositorio Git", root)
|
||||
}
|
||||
rel, err := relativePaths(root, paths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addArgs := append([]string{"add", "--"}, rel...)
|
||||
if _, err := gitOutput(root, addArgs...); err != nil {
|
||||
return "", NewError(ExitGit, "git_add_failed", err.Error(), rel)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-c", "user.name=" + identity.Admin.Name,
|
||||
"-c", "user.email=" + identity.Admin.Email,
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", "user.signingkey=" + identity.SigningKey,
|
||||
"commit", "-S", "-m", message, "--",
|
||||
}
|
||||
args = append(args, rel...)
|
||||
if _, err := gitOutput(root, args...); err != nil {
|
||||
return "", NewError(ExitGit, "git_commit_failed",
|
||||
"no se pudo crear el commit SSH firmado", map[string]any{
|
||||
"cause": err.Error(),
|
||||
"signing_key": identity.SigningKey,
|
||||
"hint": "compruebe OpenSSH/ssh-keygen y que la llave privada esté disponible o cargada en ssh-agent",
|
||||
})
|
||||
}
|
||||
sha, err := gitOutput(root, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
func verifyCommit(root, sha string) error {
|
||||
if sha == "" {
|
||||
sha = "HEAD"
|
||||
}
|
||||
allowed := filepath.Join(root, "config", "allowed_signers")
|
||||
_, err := gitOutput(root,
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", "gpg.ssh.allowedSignersFile="+allowed,
|
||||
"verify-commit", sha,
|
||||
)
|
||||
if err != nil {
|
||||
return NewError(ExitValidation, "invalid_commit_signature", "el commit no tiene una firma SSH válida y autorizada", map[string]any{
|
||||
"commit": sha,
|
||||
"cause": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func loadAdmins(root string) (AdminRegistry, error) {
|
||||
var registry AdminRegistry
|
||||
if err := loadJSON(filepath.Join(root, "data", "admins.json"), ®istry); err != nil {
|
||||
return registry, err
|
||||
}
|
||||
if registry.Version == 0 {
|
||||
registry.Version = 1
|
||||
}
|
||||
if registry.ReviewPolicy.Default == "" {
|
||||
registry.ReviewPolicy.Default = "all-active"
|
||||
registry.ReviewPolicy.IncludeAuthor = true
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func saveAdmins(root string, registry AdminRegistry) error {
|
||||
if err := writeJSONAtomic(filepath.Join(root, "data", "admins.json"), registry); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAllowedSigners(root, registry)
|
||||
}
|
||||
|
||||
func writeAllowedSigners(root string, registry AdminRegistry) error {
|
||||
lines := []string{
|
||||
"# Archivo generado por clusterlog. Contiene únicamente llaves públicas.",
|
||||
"# principal namespaces=\"git\" tipo base64",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, admin := range registry.Admins {
|
||||
if !admin.Active {
|
||||
continue
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
fields := strings.Fields(key.PublicKey)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
|
||||
if !seen[line] {
|
||||
lines = append(lines, line)
|
||||
seen[line] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(lines[2:])
|
||||
return writeFileAtomic(filepath.Join(root, "config", "allowed_signers"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func parsePublicKeyLine(line string) (canonical, fingerprint string, err error) {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 {
|
||||
return "", "", fmt.Errorf("la llave pública debe tener formato OpenSSH: tipo base64 [comentario]")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fields[1])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("base64 inválido en llave pública: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(decoded)
|
||||
fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
canonical = fields[0] + " " + fields[1]
|
||||
if len(fields) > 2 {
|
||||
canonical += " " + strings.Join(fields[2:], " ")
|
||||
}
|
||||
return canonical, fingerprint, nil
|
||||
}
|
||||
|
||||
func readPublicKey(path string) (canonical, fingerprint, publicPath, signingPath string, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", "", "", "", fmt.Errorf("ruta de llave vacía")
|
||||
}
|
||||
expanded, err := expandPath(path)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
original := expanded
|
||||
// user.signingkey suele apuntar a la llave privada; para identificar al
|
||||
// administrador siempre leemos la contraparte pública cuando existe.
|
||||
if !strings.HasSuffix(expanded, ".pub") {
|
||||
if _, pubErr := os.Stat(expanded + ".pub"); pubErr == nil {
|
||||
expanded += ".pub"
|
||||
}
|
||||
}
|
||||
info, statErr := os.Stat(expanded)
|
||||
if statErr != nil {
|
||||
return "", "", "", "", statErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", "", "", "", fmt.Errorf("%s es un directorio", expanded)
|
||||
}
|
||||
data, err := os.ReadFile(expanded)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
canonical, fingerprint, err = parsePublicKeyLine(string(data))
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
publicPath = expanded
|
||||
signingPath = expanded
|
||||
if original != expanded {
|
||||
signingPath = original
|
||||
} else if strings.HasSuffix(expanded, ".pub") {
|
||||
privateCandidate := strings.TrimSuffix(expanded, ".pub")
|
||||
if _, err := os.Stat(privateCandidate); err == nil {
|
||||
signingPath = privateCandidate
|
||||
}
|
||||
}
|
||||
return canonical, fingerprint, publicPath, signingPath, nil
|
||||
}
|
||||
|
||||
func expandPath(path string) (string, error) {
|
||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "~" {
|
||||
path = home
|
||||
} else {
|
||||
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func candidateKeyPaths(explicit string) []string {
|
||||
candidates := []string{}
|
||||
if explicit != "" {
|
||||
candidates = append(candidates, explicit)
|
||||
}
|
||||
if env := os.Getenv("CLUSTERLOG_SSH_KEY"); env != "" {
|
||||
candidates = append(candidates, env)
|
||||
}
|
||||
if output, err := exec.Command("git", "config", "--get", "user.signingkey").Output(); err == nil {
|
||||
value := strings.TrimSpace(string(output))
|
||||
if strings.HasPrefix(value, "key::") {
|
||||
// Una llave inline se identifica, pero no sirve como ruta para firmar.
|
||||
} else if value != "" {
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
for _, name := range []string{"id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"} {
|
||||
candidates = append(candidates, filepath.Join(home, ".ssh", name))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ResolveIdentity(root string) (Identity, error) {
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
attempted := []string{}
|
||||
for _, path := range candidateKeyPaths(a.Options.SSHKey) {
|
||||
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
|
||||
if err != nil {
|
||||
attempted = append(attempted, fmt.Sprintf("%s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
for _, admin := range registry.Admins {
|
||||
if !admin.Active {
|
||||
continue
|
||||
}
|
||||
for _, key := range admin.SSHKeys {
|
||||
if key.Fingerprint == fingerprint {
|
||||
return Identity{
|
||||
Admin: admin, Fingerprint: fingerprint, PublicKey: canonical,
|
||||
PublicPath: publicPath, SigningKey: signingPath,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attempted = append(attempted, fmt.Sprintf("%s: %s no está registrada", path, fingerprint))
|
||||
}
|
||||
return Identity{}, NewError(ExitIdentity, "identity_not_found",
|
||||
"ninguna llave SSH local coincide con un administrador activo", map[string]any{
|
||||
"attempted": attempted,
|
||||
"hint": "use --ssh-key ~/.ssh/id_ed25519.pub o CLUSTERLOG_SSH_KEY",
|
||||
})
|
||||
}
|
||||
|
||||
func adminByID(registry AdminRegistry, id string) (Admin, bool) {
|
||||
id = normalizeID(id)
|
||||
for _, admin := range registry.Admins {
|
||||
if normalizeID(admin.ID) == id {
|
||||
return admin, true
|
||||
}
|
||||
}
|
||||
return Admin{}, false
|
||||
}
|
||||
|
||||
func activeAdminIDs(registry AdminRegistry) []string {
|
||||
ids := []string{}
|
||||
for _, admin := range registry.Admins {
|
||||
if admin.Active {
|
||||
ids = append(ids, admin.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseMarkdown(path string) (PageMeta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return PageMeta{}, err
|
||||
}
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
if !strings.HasPrefix(text, "+++\n") {
|
||||
return PageMeta{}, fmt.Errorf("%s no tiene front matter TOML delimitado por +++", path)
|
||||
}
|
||||
rest := strings.TrimPrefix(text, "+++\n")
|
||||
idx := strings.Index(rest, "\n+++\n")
|
||||
if idx < 0 {
|
||||
if strings.HasSuffix(rest, "\n+++") {
|
||||
idx = len(rest) - len("\n+++")
|
||||
} else {
|
||||
return PageMeta{}, fmt.Errorf("%s tiene front matter sin cierre +++", path)
|
||||
}
|
||||
}
|
||||
rawFront := rest[:idx]
|
||||
bodyStart := idx + len("\n+++\n")
|
||||
body := ""
|
||||
if bodyStart <= len(rest) {
|
||||
body = rest[bodyStart:]
|
||||
}
|
||||
|
||||
meta := PageMeta{
|
||||
Path: path,
|
||||
Body: body,
|
||||
Root: map[string]any{},
|
||||
Taxonomies: map[string][]string{},
|
||||
Extra: map[string]any{},
|
||||
RawFront: rawFront,
|
||||
ContentHash: bytesSHA256(data),
|
||||
}
|
||||
|
||||
section := "root"
|
||||
scanner := bufio.NewScanner(strings.NewReader(rawFront))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(stripTOMLComment(scanner.Text()))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
|
||||
continue
|
||||
}
|
||||
key, raw, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
raw = strings.TrimSpace(raw)
|
||||
value, err := parseTOMLValue(raw)
|
||||
if err != nil {
|
||||
return PageMeta{}, fmt.Errorf("%s:%d: %w", path, lineNo, err)
|
||||
}
|
||||
switch section {
|
||||
case "root":
|
||||
meta.Root[key] = value
|
||||
case "taxonomies":
|
||||
values, ok := value.([]string)
|
||||
if ok {
|
||||
meta.Taxonomies[key] = values
|
||||
}
|
||||
case "extra":
|
||||
meta.Extra[key] = value
|
||||
default:
|
||||
// El parser intencionalmente ignora secciones no usadas por clusterlog.
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return PageMeta{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func stripTOMLComment(line string) string {
|
||||
inString := false
|
||||
escaped := false
|
||||
for i, r := range line {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' && inString {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if r == '#' && !inString {
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func parseTOMLValue(raw string) (any, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "\"") {
|
||||
value, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cadena TOML inválida: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
if raw == "true" {
|
||||
return true, nil
|
||||
}
|
||||
if raw == "false" {
|
||||
return false, nil
|
||||
}
|
||||
if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
|
||||
return parseTOMLStringArray(raw)
|
||||
}
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
// Fechas TOML y otros escalares simples se conservan como texto.
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func parseTOMLStringArray(raw string) ([]string, error) {
|
||||
content := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]"))
|
||||
if content == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
var values []string
|
||||
var current strings.Builder
|
||||
inString := false
|
||||
escaped := false
|
||||
flush := func() error {
|
||||
item := strings.TrimSpace(current.String())
|
||||
current.Reset()
|
||||
if item == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.Unquote(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("arreglo TOML inválido: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
return nil
|
||||
}
|
||||
for _, r := range content {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' && inString {
|
||||
current.WriteRune(r)
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '"' {
|
||||
inString = !inString
|
||||
current.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == ',' && !inString {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.WriteRune(r)
|
||||
}
|
||||
if inString {
|
||||
return nil, fmt.Errorf("arreglo TOML con cadena sin cerrar")
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func pageSlug(meta PageMeta) string {
|
||||
if value := meta.String("slug"); value != "" {
|
||||
return value
|
||||
}
|
||||
return slugify(strings.TrimSuffix(filepathBase(meta.Path), ".md"))
|
||||
}
|
||||
|
||||
func filepathBase(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
return path[idx+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMarkdownFrontMatter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "page.md")
|
||||
mustWriteTestFile(t, path, `+++
|
||||
title = "Prueba # literal"
|
||||
description = "Página de prueba"
|
||||
date = 2026-08-12T08:30:00Z
|
||||
authors = ["ana", "bob"]
|
||||
slug = "pagina-prueba"
|
||||
|
||||
[taxonomies]
|
||||
tags = ["red", "cambio"]
|
||||
systems = ["spine-01"]
|
||||
|
||||
[extra]
|
||||
id = "MEM-TEST-001"
|
||||
review_required = true
|
||||
duration_minutes = 45
|
||||
reviewers = ["ana", "bob"] # comentario
|
||||
+++
|
||||
|
||||
## Cuerpo
|
||||
|
||||
Contenido.
|
||||
`)
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.String("title") != "Prueba # literal" {
|
||||
t.Fatalf("unexpected title: %q", meta.String("title"))
|
||||
}
|
||||
if meta.ExtraString("id") != "MEM-TEST-001" || !meta.ExtraBool("review_required") || meta.ExtraInt("duration_minutes") != 45 {
|
||||
t.Fatalf("unexpected extra metadata: %#v", meta.Extra)
|
||||
}
|
||||
if got := meta.Taxonomies["tags"]; len(got) != 2 || got[0] != "red" || got[1] != "cambio" {
|
||||
t.Fatalf("unexpected tags: %#v", got)
|
||||
}
|
||||
if meta.ContentHash == "" || meta.Body == "" {
|
||||
t.Fatal("expected content hash and body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownRejectsUnclosedFrontMatter(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.md")
|
||||
mustWriteTestFile(t, path, "+++\ntitle = \"sin cierre\"\n")
|
||||
if _, err := parseMarkdown(path); err == nil {
|
||||
t.Fatal("expected invalid front matter to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type inputSchema struct {
|
||||
Schema string `json:"$schema"`
|
||||
ID string `json:"$id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties map[string]map[string]any `json:"properties"`
|
||||
Additional bool `json:"additionalProperties"`
|
||||
Examples []any `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
func property(kind, description string) map[string]any {
|
||||
return map[string]any{"type": kind, "description": description}
|
||||
}
|
||||
|
||||
func stringArray(description string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "array", "description": description,
|
||||
"items": map[string]any{"type": "string"}, "uniqueItems": true,
|
||||
}
|
||||
}
|
||||
|
||||
func enumProperty(description string, values ...string) map[string]any {
|
||||
items := make([]any, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = value
|
||||
}
|
||||
return map[string]any{"type": "string", "description": description, "enum": items}
|
||||
}
|
||||
|
||||
func schemaFor(name string) (inputSchema, bool) {
|
||||
base := inputSchema{
|
||||
Schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
ID: "https://clusterlog.local/schemas/" + name + ".json",
|
||||
Type: "object", Additional: false,
|
||||
}
|
||||
switch name {
|
||||
case "memory":
|
||||
base.Title = "Entrada de memoria operativa"
|
||||
base.Description = "Contrato de entrada para clusterlog memory create --from-json. Nunca incluya secretos sin redactar."
|
||||
base.Required = []string{"title"}
|
||||
base.Properties = map[string]map[string]any{
|
||||
"title": property("string", "Título breve y accionable"),
|
||||
"description": property("string", "Resumen para listados"),
|
||||
"tags": stringArray("Etiquetas temáticas"),
|
||||
"systems": stringArray("Sistemas o componentes afectados"),
|
||||
"risk": enumProperty("Nivel de riesgo", "low", "medium", "high", "critical"),
|
||||
"ticket": property("string", "Ticket, incidente o cambio relacionado"),
|
||||
"review_required": property("boolean", "Incluye la entrada en la cola de revisión"),
|
||||
"review_policy": enumProperty("Quién debe revisar", "all-active", "all-active-except-author"),
|
||||
"body_markdown": property("string", "Cuerpo Markdown completo; sustituye las secciones estructuradas"),
|
||||
"context": property("string", "Contexto de la intervención"),
|
||||
"changes": stringArray("Cambios realizados"),
|
||||
"commands": stringArray("Comandos relevantes con secretos redactados"),
|
||||
"validation": stringArray("Comprobaciones ejecutadas"),
|
||||
"rollback": property("string", "Procedimiento de reversión"),
|
||||
"started_at": property("string", "Inicio RFC3339"),
|
||||
"finished_at": property("string", "Fin RFC3339"),
|
||||
"duration_minutes": property("integer", "Duración total en minutos"),
|
||||
"agent": property("string", "Agente que preparó el borrador, por ejemplo claude-code"),
|
||||
}
|
||||
base.Examples = []any{MemoryInput{
|
||||
Title: "Actualizar firmware del switch spine-01", Description: "Cambio controlado y validación de enlaces",
|
||||
Tags: []string{"red", "firmware"}, Systems: []string{"spine-01"}, Risk: "high", Ticket: "CHG-1042",
|
||||
Changes: []string{"Respaldé la configuración", "Instalé la versión aprobada"},
|
||||
Commands: []string{"show version", "show interfaces status"},
|
||||
Validation: []string{"Todos los uplinks quedaron up", "Sin errores nuevos en logs"},
|
||||
Rollback: "Arrancar la imagen anterior y restaurar la configuración respaldada.", Agent: "claude-code",
|
||||
}}
|
||||
case "task":
|
||||
base.Title = "Tarea compartida"
|
||||
base.Description = "Contrato de entrada para clusterlog task create --from-json."
|
||||
base.Required = []string{"title"}
|
||||
base.Properties = map[string]map[string]any{
|
||||
"title": property("string", "Título de la tarea"),
|
||||
"description": property("string", "Resumen"),
|
||||
"project_id": property("string", "Proyecto relacionado, si existe"),
|
||||
"priority": enumProperty("Prioridad", "low", "medium", "high", "urgent"),
|
||||
"assignees": stringArray("IDs de administradores responsables"),
|
||||
"estimate_minutes": property("integer", "Estimación en minutos"),
|
||||
"due": property("string", "Fecha límite YYYY-MM-DD"),
|
||||
"tags": stringArray("Etiquetas"),
|
||||
"systems": stringArray("Sistemas afectados"),
|
||||
"body_markdown": property("string", "Cuerpo Markdown completo"),
|
||||
"objective": property("string", "Resultado esperado"),
|
||||
"acceptance_criteria": stringArray("Criterios verificables"),
|
||||
"notes": property("string", "Notas de ejecución"),
|
||||
}
|
||||
base.Examples = []any{TaskInput{Title: "Probar restauración de etcd", Priority: "high", EstimateMinutes: 120, Due: "2026-09-15", Objective: "Validar el RTO del plano de control", Acceptance: []string{"Restauración documentada", "RTO menor a 60 minutos"}}}
|
||||
case "project":
|
||||
base.Title = "Proyecto de operación"
|
||||
base.Description = "Contrato de entrada para clusterlog project create --from-json."
|
||||
base.Required = []string{"title", "objective"}
|
||||
base.Properties = map[string]map[string]any{
|
||||
"title": property("string", "Nombre del proyecto"),
|
||||
"description": property("string", "Resumen ejecutivo"),
|
||||
"objective": property("string", "Objetivo medible"),
|
||||
"owners": stringArray("IDs de administradores responsables"),
|
||||
"target_date": property("string", "Fecha objetivo YYYY-MM-DD"),
|
||||
"status": enumProperty("Estado", "planned", "active", "blocked", "done", "cancelled", "archived"),
|
||||
"tags": stringArray("Etiquetas"),
|
||||
"systems": stringArray("Sistemas incluidos"),
|
||||
"body_markdown": property("string", "Cuerpo Markdown completo"),
|
||||
"scope": stringArray("Elementos dentro del alcance"),
|
||||
"out_of_scope": stringArray("Elementos fuera del alcance"),
|
||||
"milestones": stringArray("Hitos"),
|
||||
"success_criteria": stringArray("Criterios de éxito"),
|
||||
}
|
||||
base.Examples = []any{ProjectInput{Title: "Estandarizar observabilidad", Objective: "Unificar métricas, logs y alertas del clúster", Status: "planned", Scope: []string{"Inventario de alertas", "Dashboards base"}, Success: []string{"100% de nodos reportando métricas"}}}
|
||||
case "content":
|
||||
base.Title = "Documento o manual"
|
||||
base.Description = "Contrato de entrada para clusterlog content create --from-json."
|
||||
base.Required = []string{"section", "title", "body_markdown"}
|
||||
base.Properties = map[string]map[string]any{
|
||||
"section": enumProperty("Sección principal", "documentacion", "manuales"),
|
||||
"title": property("string", "Título"),
|
||||
"description": property("string", "Resumen"),
|
||||
"tags": stringArray("Etiquetas"),
|
||||
"systems": stringArray("Sistemas relacionados"),
|
||||
"body_markdown": property("string", "Contenido Markdown"),
|
||||
"weight": property("integer", "Orden opcional dentro de la sección"),
|
||||
}
|
||||
base.Examples = []any{ContentInput{Section: "manuales", Title: "Reemplazo seguro de un nodo de cómputo", Tags: []string{"runbook"}, BodyMarkdown: "## Prerrequisitos\n\n...\n"}}
|
||||
default:
|
||||
return inputSchema{}, false
|
||||
}
|
||||
return base, true
|
||||
}
|
||||
|
||||
func (a *App) runSchema(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return a.printResult("schema", map[string]any{
|
||||
"available": []string{"memory", "task", "project", "content"},
|
||||
"usage": a.Options.Executable + " --json schema memory",
|
||||
"stdin_example": "cat input.json | " + a.Options.Executable + " --json --commit memory create --from-json -",
|
||||
})
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return NewError(ExitUsage, "schema_usage", "uso: schema [memory|task|project|content]", args)
|
||||
}
|
||||
name := strings.ToLower(args[0])
|
||||
schema, ok := schemaFor(name)
|
||||
if !ok {
|
||||
return NewError(ExitUsage, "unknown_schema", "esquema desconocido", name)
|
||||
}
|
||||
// Convertir a un mapa preserva el documento JSON Schema como dato del sobre de salida.
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(data, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printResult("schema."+name, document)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const siteStateVersion = 2
|
||||
|
||||
func loadReviewLog(root string) (ReviewLog, error) {
|
||||
log := ReviewLog{Version: 1, Events: []ReviewEvent{}}
|
||||
files, err := reviewEventFiles(root)
|
||||
if err != nil {
|
||||
return log, err
|
||||
}
|
||||
for _, path := range files {
|
||||
var event ReviewEvent
|
||||
if err := loadJSON(path, &event); err != nil {
|
||||
return log, err
|
||||
}
|
||||
log.Events = append(log.Events, event)
|
||||
}
|
||||
sortReviewEvents(log.Events)
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func loadTaskLog(root string) (TaskLog, error) {
|
||||
log := TaskLog{Version: 1, Events: []TaskEvent{}}
|
||||
files, err := taskEventFiles(root)
|
||||
if err != nil {
|
||||
return log, err
|
||||
}
|
||||
for _, path := range files {
|
||||
var event TaskEvent
|
||||
if err := loadJSON(path, &event); err != nil {
|
||||
return log, err
|
||||
}
|
||||
log.Events = append(log.Events, event)
|
||||
}
|
||||
sortTaskEvents(log.Events)
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func requiredReviewers(registry AdminRegistry, policy, authorID string) []string {
|
||||
policy = firstNonEmpty(policy, registry.ReviewPolicy.Default)
|
||||
required := activeAdminIDs(registry)
|
||||
excludeAuthor := policy == "all-active-except-author" ||
|
||||
(policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)
|
||||
if !excludeAuthor || authorID == "" {
|
||||
return required
|
||||
}
|
||||
filtered := make([]string, 0, len(required))
|
||||
for _, id := range required {
|
||||
if id != authorID {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (a *App) BuildState(root string) (SiteState, error) {
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
reviews, err := loadReviewLog(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
tasksLog, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
memoryFiles, err := listMarkdownFiles(root, "memorias")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
taskFiles, err := listMarkdownFiles(root, "tareas")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
projectFiles, err := listMarkdownFiles(root, "proyectos")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
reviewFiles, err := reviewEventFiles(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
taskEventPaths, err := taskEventFiles(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
sourcePaths := []string{filepath.Join(root, "data", "admins.json")}
|
||||
sourcePaths = append(sourcePaths, reviewFiles...)
|
||||
sourcePaths = append(sourcePaths, taskEventPaths...)
|
||||
sourcePaths = append(sourcePaths, memoryFiles...)
|
||||
sourcePaths = append(sourcePaths, taskFiles...)
|
||||
sourcePaths = append(sourcePaths, projectFiles...)
|
||||
digest, err := sourceDigest(sourcePaths)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
memories := make([]MemoryRecord, 0, len(memoryFiles))
|
||||
for _, path := range memoryFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
record := MemoryRecord{
|
||||
EntryID: id,
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Date: meta.String("date"),
|
||||
AuthorID: meta.ExtraString("author_id"),
|
||||
AuthorFingerprint: meta.ExtraString("author_fingerprint"),
|
||||
Risk: firstNonEmpty(meta.ExtraString("risk"), "low"),
|
||||
Ticket: meta.ExtraString("ticket"),
|
||||
ReviewRequired: meta.ExtraBool("review_required"),
|
||||
ReviewPolicy: meta.ExtraString("review_policy"),
|
||||
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"),
|
||||
Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
|
||||
Tags: uniqueSorted(meta.Taxonomies["tags"]),
|
||||
Systems: uniqueSorted(meta.Taxonomies["systems"]),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
ContentSHA256: meta.ContentHash,
|
||||
}
|
||||
record.Permalink = "/memorias/" + record.Slug + "/"
|
||||
if record.ReviewPolicy == "" {
|
||||
record.ReviewPolicy = registry.ReviewPolicy.Default
|
||||
}
|
||||
memories = append(memories, record)
|
||||
}
|
||||
sort.Slice(memories, func(i, j int) bool {
|
||||
if memories[i].Date == memories[j].Date {
|
||||
return memories[i].EntryID > memories[j].EntryID
|
||||
}
|
||||
return memories[i].Date > memories[j].Date
|
||||
})
|
||||
|
||||
activeIDs := activeAdminIDs(registry)
|
||||
queue := []ReviewQueueItem{}
|
||||
for _, memory := range memories {
|
||||
if !memory.ReviewRequired {
|
||||
continue
|
||||
}
|
||||
var required []string
|
||||
if memory.ReviewerSnapshot {
|
||||
required = append([]string(nil), memory.Reviewers...)
|
||||
} else {
|
||||
required = requiredReviewers(registry, memory.ReviewPolicy, memory.AuthorID)
|
||||
}
|
||||
|
||||
reviewedSet := map[string]bool{}
|
||||
for _, event := range reviews.Events {
|
||||
if normalizeID(event.EntryID) != memory.EntryID || event.ContentSHA256 != memory.ContentSHA256 {
|
||||
continue
|
||||
}
|
||||
if contains(required, event.AdminID) {
|
||||
reviewedSet[event.AdminID] = true
|
||||
}
|
||||
}
|
||||
reviewed := []string{}
|
||||
missing := []string{}
|
||||
for _, id := range required {
|
||||
if reviewedSet[id] {
|
||||
reviewed = append(reviewed, id)
|
||||
} else {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
item := ReviewQueueItem{
|
||||
EntryID: memory.EntryID, Title: memory.Title, Description: memory.Description,
|
||||
Date: memory.Date, AuthorID: memory.AuthorID, Risk: memory.Risk,
|
||||
Tags: memory.Tags, Systems: memory.Systems, Permalink: memory.Permalink,
|
||||
ContentSHA256: memory.ContentSHA256, ReviewedBy: reviewed,
|
||||
MissingReviewers: missing, RequiredCount: len(required), ReviewedCount: len(reviewed),
|
||||
}
|
||||
if !memory.ReviewerSnapshot && len(activeIDs) == 0 {
|
||||
item.ConfigurationError = "No hay administradores activos; registre al menos uno antes de cerrar revisiones."
|
||||
}
|
||||
if item.ConfigurationError != "" || len(missing) > 0 {
|
||||
queue = append(queue, item)
|
||||
}
|
||||
}
|
||||
|
||||
taskStates := make([]TaskState, 0, len(taskFiles))
|
||||
taskIndex := map[string]int{}
|
||||
for _, path := range taskFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
state := TaskState{
|
||||
TaskID: id,
|
||||
RunningBy: []string{},
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "todo"),
|
||||
Priority: firstNonEmpty(meta.ExtraString("priority"), "medium"),
|
||||
ProjectID: normalizeID(meta.ExtraString("project_id")),
|
||||
Assignees: uniqueSorted(meta.ExtraStrings("assignees")),
|
||||
EstimateMinutes: meta.ExtraInt("estimate_minutes"),
|
||||
Due: meta.ExtraString("due"),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
state.Permalink = "/tareas/" + state.Slug + "/"
|
||||
taskIndex[id] = len(taskStates)
|
||||
taskStates = append(taskStates, state)
|
||||
}
|
||||
running := map[string]map[string]bool{}
|
||||
for _, event := range tasksLog.Events {
|
||||
id := normalizeID(event.TaskID)
|
||||
idx, ok := taskIndex[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
state := &taskStates[idx]
|
||||
actor := event.AdminID
|
||||
switch event.Type {
|
||||
case "start":
|
||||
if running[id] == nil {
|
||||
running[id] = map[string]bool{}
|
||||
}
|
||||
running[id][actor] = true
|
||||
if state.Status == "todo" || state.Status == "backlog" {
|
||||
state.Status = "in_progress"
|
||||
}
|
||||
case "stop":
|
||||
state.ActualMinutes += event.Minutes
|
||||
if running[id] != nil {
|
||||
delete(running[id], actor)
|
||||
}
|
||||
case "adjust":
|
||||
state.ActualMinutes += event.Minutes
|
||||
case "status":
|
||||
if event.Status != "" {
|
||||
state.Status = event.Status
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, actors := range running {
|
||||
idx, ok := taskIndex[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for actor, isRunning := range actors {
|
||||
if isRunning {
|
||||
taskStates[idx].RunningBy = append(taskStates[idx].RunningBy, actor)
|
||||
}
|
||||
}
|
||||
sort.Strings(taskStates[idx].RunningBy)
|
||||
}
|
||||
sort.Slice(taskStates, func(i, j int) bool {
|
||||
order := map[string]int{"in_progress": 0, "blocked": 1, "todo": 2, "backlog": 3, "done": 4, "cancelled": 5}
|
||||
oi, ok := order[taskStates[i].Status]
|
||||
if !ok {
|
||||
oi = 9
|
||||
}
|
||||
oj, ok := order[taskStates[j].Status]
|
||||
if !ok {
|
||||
oj = 9
|
||||
}
|
||||
if oi == oj {
|
||||
if taskStates[i].Due == taskStates[j].Due {
|
||||
return taskStates[i].TaskID < taskStates[j].TaskID
|
||||
}
|
||||
if taskStates[i].Due == "" {
|
||||
return false
|
||||
}
|
||||
if taskStates[j].Due == "" {
|
||||
return true
|
||||
}
|
||||
return taskStates[i].Due < taskStates[j].Due
|
||||
}
|
||||
return oi < oj
|
||||
})
|
||||
|
||||
projects := make([]ProjectState, 0, len(projectFiles))
|
||||
for _, path := range projectFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
project := ProjectState{
|
||||
ProjectID: id,
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "planned"),
|
||||
Owners: uniqueSorted(meta.ExtraStrings("owners")),
|
||||
TargetDate: meta.ExtraString("target_date"),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
project.Permalink = "/proyectos/" + project.Slug + "/"
|
||||
projects = append(projects, project)
|
||||
}
|
||||
sort.Slice(projects, func(i, j int) bool {
|
||||
return projects[i].ProjectID < projects[j].ProjectID
|
||||
})
|
||||
|
||||
openTasks := 0
|
||||
for _, task := range taskStates {
|
||||
if task.Status != "done" && task.Status != "cancelled" {
|
||||
openTasks++
|
||||
}
|
||||
}
|
||||
activeProjects := 0
|
||||
for _, project := range projects {
|
||||
switch project.Status {
|
||||
case "done", "completed", "cancelled", "archived":
|
||||
default:
|
||||
activeProjects++
|
||||
}
|
||||
}
|
||||
recent := memories
|
||||
if len(recent) > 30 {
|
||||
recent = recent[:30]
|
||||
}
|
||||
return SiteState{
|
||||
Version: siteStateVersion,
|
||||
SourceSHA256: digest,
|
||||
Stats: SiteStats{
|
||||
ActiveAdmins: len(activeIDs), PendingReviews: len(queue),
|
||||
OpenTasks: openTasks, ActiveProjects: activeProjects,
|
||||
},
|
||||
ReviewQueue: queue,
|
||||
RecentMemories: recent,
|
||||
Tasks: taskStates,
|
||||
Projects: projects,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Sync(root string, check bool) (SiteState, bool, string, error) {
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return SiteState{}, false, "", err
|
||||
}
|
||||
path := filepath.Join(root, "data", "generated", "site_state.json")
|
||||
var old SiteState
|
||||
oldErr := loadJSON(path, &old)
|
||||
upToDate := oldErr == nil && old.Version == state.Version && old.SourceSHA256 == state.SourceSHA256
|
||||
if check {
|
||||
if !upToDate {
|
||||
return state, false, path, NewError(ExitValidation, "generated_state_stale",
|
||||
"data/generated/site_state.json no corresponde a las fuentes actuales", map[string]any{
|
||||
"expected_source_sha256": state.SourceSHA256,
|
||||
"actual_source_sha256": old.SourceSHA256,
|
||||
"hint": "ejecute clusterlog sync",
|
||||
})
|
||||
}
|
||||
return old, false, path, nil
|
||||
}
|
||||
if upToDate {
|
||||
return old, false, path, nil
|
||||
}
|
||||
state.GeneratedAt = a.Options.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if a.Options.DryRun {
|
||||
return state, true, path, nil
|
||||
}
|
||||
if err := writeJSONAtomic(path, state); err != nil {
|
||||
return SiteState{}, false, path, err
|
||||
}
|
||||
return state, true, path, nil
|
||||
}
|
||||
|
||||
func findMemory(root, id string) (MemoryRecord, PageMeta, error) {
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "memorias")
|
||||
if err != nil {
|
||||
return MemoryRecord{}, PageMeta{}, err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return MemoryRecord{}, PageMeta{}, err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
record := MemoryRecord{
|
||||
EntryID: id,
|
||||
Title: meta.String("title"), Description: meta.String("description"),
|
||||
Date: meta.String("date"), AuthorID: meta.ExtraString("author_id"),
|
||||
AuthorFingerprint: meta.ExtraString("author_fingerprint"), Risk: meta.ExtraString("risk"),
|
||||
ReviewRequired: meta.ExtraBool("review_required"), ReviewPolicy: meta.ExtraString("review_policy"),
|
||||
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
|
||||
Tags: meta.Taxonomies["tags"], Systems: meta.Taxonomies["systems"],
|
||||
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), ContentSHA256: meta.ContentHash,
|
||||
}
|
||||
record.Permalink = "/memorias/" + record.Slug + "/"
|
||||
return record, meta, nil
|
||||
}
|
||||
}
|
||||
return MemoryRecord{}, PageMeta{}, NewError(ExitUsage, "memory_not_found", "no existe la memoria indicada", id)
|
||||
}
|
||||
|
||||
func findTask(root, id string) (TaskState, PageMeta, error) {
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "tareas")
|
||||
if err != nil {
|
||||
return TaskState{}, PageMeta{}, err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return TaskState{}, PageMeta{}, err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
state := TaskState{
|
||||
TaskID: id, Title: meta.String("title"), Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "todo"), Priority: meta.ExtraString("priority"),
|
||||
ProjectID: normalizeID(meta.ExtraString("project_id")), Assignees: meta.ExtraStrings("assignees"),
|
||||
EstimateMinutes: meta.ExtraInt("estimate_minutes"), Due: meta.ExtraString("due"),
|
||||
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
state.Permalink = "/tareas/" + state.Slug + "/"
|
||||
return state, meta, nil
|
||||
}
|
||||
}
|
||||
return TaskState{}, PageMeta{}, NewError(ExitUsage, "task_not_found", "no existe la tarea indicada", id)
|
||||
}
|
||||
|
||||
func taskActiveStart(log TaskLog, adminID string) (TaskEvent, bool) {
|
||||
active := map[string]TaskEvent{}
|
||||
for _, event := range log.Events {
|
||||
if event.AdminID != adminID {
|
||||
continue
|
||||
}
|
||||
id := normalizeID(event.TaskID)
|
||||
switch event.Type {
|
||||
case "start":
|
||||
active[id] = event
|
||||
case "stop":
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return TaskEvent{}, false
|
||||
}
|
||||
keys := make([]string, 0, len(active))
|
||||
for id := range active {
|
||||
keys = append(keys, id)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return active[keys[0]], true
|
||||
}
|
||||
|
||||
func taskIsRunning(log TaskLog, taskID, adminID string) (TaskEvent, bool) {
|
||||
taskID = normalizeID(taskID)
|
||||
var current TaskEvent
|
||||
running := false
|
||||
for _, event := range log.Events {
|
||||
if normalizeID(event.TaskID) != taskID || event.AdminID != adminID {
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case "start":
|
||||
current = event
|
||||
running = true
|
||||
case "stop":
|
||||
running = false
|
||||
}
|
||||
}
|
||||
return current, running
|
||||
}
|
||||
|
||||
func ensureProjectExists(root, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
}
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "proyectos")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return NewError(ExitValidation, "project_not_found", "la tarea referencia un proyecto inexistente", id)
|
||||
}
|
||||
|
||||
func generatedStateExists(root string) bool {
|
||||
_, err := os.Stat(filepath.Join(root, "data", "generated", "site_state.json"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func statusIsClosed(status string) bool {
|
||||
switch strings.ToLower(status) {
|
||||
case "done", "completed", "cancelled", "archived":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pageIDDuplicates(records []string) []string {
|
||||
counts := map[string]int{}
|
||||
for _, id := range records {
|
||||
counts[id]++
|
||||
}
|
||||
out := []string{}
|
||||
for id, count := range counts {
|
||||
if id != "" && count > 1 {
|
||||
out = append(out, fmt.Sprintf("%s (%d)", id, count))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReviewQueueUsesContentHashAndReviewerSnapshot(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
ana := testAdmin(t, "ana", 1, "owner", "admin")
|
||||
bob := testAdmin(t, "bob", 2, "admin")
|
||||
registry := AdminRegistry{
|
||||
Version: 1,
|
||||
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
|
||||
Admins: []Admin{ana, bob},
|
||||
}
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memoryPath := filepath.Join(root, "content", "memorias", "memory.md")
|
||||
mustWriteTestFile(t, memoryPath, `+++
|
||||
title = "Cambio de red"
|
||||
description = "Ajuste controlado"
|
||||
date = 2026-08-12T10:00:00Z
|
||||
authors = ["ana"]
|
||||
slug = "cambio-red"
|
||||
|
||||
[taxonomies]
|
||||
tags = ["red"]
|
||||
systems = ["spine-01"]
|
||||
|
||||
[extra]
|
||||
id = "MEM-TEST-001"
|
||||
author_id = "ana"
|
||||
author_fingerprint = "`+ana.SSHKeys[0].Fingerprint+`"
|
||||
risk = "high"
|
||||
review_required = true
|
||||
review_policy = "all-active"
|
||||
reviewer_snapshot = true
|
||||
reviewers = ["ana", "bob"]
|
||||
+++
|
||||
|
||||
## Cambio
|
||||
|
||||
Versión uno.
|
||||
`)
|
||||
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) }})
|
||||
state, err := app.BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.ReviewQueue) != 1 || len(state.ReviewQueue[0].MissingReviewers) != 2 {
|
||||
t.Fatalf("unexpected initial queue: %#v", state.ReviewQueue)
|
||||
}
|
||||
memory, _, err := findMemory(root, "MEM-TEST-001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := ReviewEvent{
|
||||
ID: "REV-TEST-001", EntryID: memory.EntryID, AdminID: "ana",
|
||||
Fingerprint: ana.SSHKeys[0].Fingerprint, ReviewedAt: "2026-08-12T12:01:00Z",
|
||||
ContentSHA256: memory.ContentSHA256,
|
||||
}
|
||||
if _, err := writeReviewEvent(root, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err = app.BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 1 || got[0] != "bob" {
|
||||
t.Fatalf("unexpected missing reviewers after review: %#v", got)
|
||||
}
|
||||
|
||||
// Adding a new administrator must not reopen old entries because the
|
||||
// required reviewer set is captured when the memory is created.
|
||||
carol := testAdmin(t, "carol", 3, "admin")
|
||||
registry.Admins = append(registry.Admins, carol)
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err = app.BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.ReviewQueue[0].RequiredCount != 2 || contains(state.ReviewQueue[0].MissingReviewers, "carol") {
|
||||
t.Fatalf("snapshot unexpectedly changed: %#v", state.ReviewQueue[0])
|
||||
}
|
||||
|
||||
// Any edit changes the hash, invalidating the old review automatically.
|
||||
data, err := os.ReadFile(memoryPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(memoryPath, append(data, []byte("\nCorrección posterior.\n")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err = app.BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 2 || !contains(got, "ana") || !contains(got, "bob") {
|
||||
t.Fatalf("old reviews were not invalidated: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskProjectionFromIndependentEventFiles(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
ana := testAdmin(t, "ana", 1, "owner", "admin")
|
||||
if err := saveAdmins(root, AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{ana}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join(root, "content", "tareas", "task.md"), `+++
|
||||
title = "Probar respaldo"
|
||||
description = "Prueba de tarea"
|
||||
date = 2026-08-12T10:00:00Z
|
||||
authors = ["ana"]
|
||||
slug = "probar-respaldo"
|
||||
|
||||
[taxonomies]
|
||||
tags = ["backup"]
|
||||
systems = ["etcd"]
|
||||
|
||||
[extra]
|
||||
id = "TSK-TEST-001"
|
||||
status = "todo"
|
||||
priority = "high"
|
||||
project_id = ""
|
||||
assignees = ["ana"]
|
||||
estimate_minutes = 60
|
||||
due = "2026-08-20"
|
||||
+++
|
||||
|
||||
## Objetivo
|
||||
|
||||
Validar el respaldo.
|
||||
`)
|
||||
events := []TaskEvent{
|
||||
{ID: "EVT-3", TaskID: "TSK-TEST-001", Type: "status", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:30:00Z", Status: "blocked"},
|
||||
{ID: "EVT-1", TaskID: "TSK-TEST-001", Type: "start", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:00:00Z"},
|
||||
{ID: "EVT-2", TaskID: "TSK-TEST-001", Type: "stop", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:25:00Z", Minutes: 25},
|
||||
{ID: "EVT-4", TaskID: "TSK-TEST-001", Type: "adjust", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:31:00Z", Minutes: 5},
|
||||
}
|
||||
// Intentionally write out of chronological order: loading must sort by At.
|
||||
if _, err := writeTaskEvents(root, events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := New(Options{}).BuildState(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Tasks) != 1 {
|
||||
t.Fatalf("unexpected tasks: %#v", state.Tasks)
|
||||
}
|
||||
got := state.Tasks[0]
|
||||
if got.ActualMinutes != 30 || got.Status != "blocked" || len(got.RunningBy) != 0 || got.RunningBy == nil {
|
||||
t.Fatalf("unexpected task projection: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRegeneratesWhenProjectionVersionChanges(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 15, 0, 0, 0, time.UTC) }})
|
||||
state, changed, path, err := app.Sync(root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || state.Version != siteStateVersion {
|
||||
t.Fatalf("unexpected initial sync: changed=%v state=%#v", changed, state)
|
||||
}
|
||||
|
||||
var stale SiteState
|
||||
if err := loadJSON(path, &stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale.Version = siteStateVersion - 1
|
||||
if err := writeJSONAtomic(path, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, _, err := app.Sync(root, true); err == nil {
|
||||
t.Fatal("sync --check accepted a stale projection version")
|
||||
}
|
||||
state, changed, _, err = app.Sync(root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || state.Version != siteStateVersion {
|
||||
t.Fatalf("projection was not regenerated: changed=%v state=%#v", changed, state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func loadJSON(path string, dst any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(data, dst); err != nil {
|
||||
return fmt.Errorf("JSON inválido en %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return writeFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".clusterlog-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func readAll(path string, stdin io.Reader) ([]byte, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if path == "-" {
|
||||
return io.ReadAll(stdin)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func bytesSHA256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func generateID(prefix string, now time.Time) (string, error) {
|
||||
random := make([]byte, 3)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s", strings.ToUpper(prefix), now.UTC().Format("20060102"), strings.ToUpper(hex.EncodeToString(random))), nil
|
||||
}
|
||||
|
||||
func normalizeID(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func slugify(value string) string {
|
||||
replacements := map[rune]rune{
|
||||
'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'Á': 'a', 'À': 'a', 'Ä': 'a', 'Â': 'a',
|
||||
'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'É': 'e', 'È': 'e', 'Ë': 'e', 'Ê': 'e',
|
||||
'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'Í': 'i', 'Ì': 'i', 'Ï': 'i', 'Î': 'i',
|
||||
'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'Ó': 'o', 'Ò': 'o', 'Ö': 'o', 'Ô': 'o',
|
||||
'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'Ú': 'u', 'Ù': 'u', 'Ü': 'u', 'Û': 'u',
|
||||
'ñ': 'n', 'Ñ': 'n', 'ç': 'c', 'Ç': 'c',
|
||||
}
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
if replacement, ok := replacements[r]; ok {
|
||||
r = replacement
|
||||
}
|
||||
r = unicode.ToLower(r)
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDurationMinutes(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("la duración no puede ser negativa")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("duración inválida %q; use minutos, 90m, 2h o 1h30m", value)
|
||||
}
|
||||
if duration < 0 {
|
||||
return 0, fmt.Errorf("la duración no puede ser negativa")
|
||||
}
|
||||
return int(duration.Round(time.Minute) / time.Minute), nil
|
||||
}
|
||||
|
||||
func formatMinutes(minutes int) string {
|
||||
if minutes <= 0 {
|
||||
return "0m"
|
||||
}
|
||||
hours := minutes / 60
|
||||
rest := minutes % 60
|
||||
if hours == 0 {
|
||||
return fmt.Sprintf("%dm", rest)
|
||||
}
|
||||
if rest == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", hours, rest)
|
||||
}
|
||||
|
||||
func listMarkdownFiles(root, section string) ([]string, error) {
|
||||
base := filepath.Join(root, "content", section)
|
||||
entries := []string{}
|
||||
err := 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" || filepath.Base(path) == "_index.md" {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, path)
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
sort.Strings(entries)
|
||||
return entries, err
|
||||
}
|
||||
|
||||
func sourceDigest(paths []string) (string, error) {
|
||||
sort.Strings(paths)
|
||||
h := sha256.New()
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = h.Write([]byte(filepath.ToSlash(path)))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write(data)
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
seen[value] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasRole(admin Admin, role string) bool {
|
||||
return contains(admin.Roles, role)
|
||||
}
|
||||
|
||||
func relativePaths(root string, paths []string) ([]string, error) {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateDate(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return fmt.Errorf("fecha inválida %q; use YYYY-MM-DD", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var secretPatterns = []struct {
|
||||
Name string
|
||||
Pattern *regexp.Regexp
|
||||
}{
|
||||
{"llave privada OpenSSH", regexp.MustCompile(`(?i)BEGIN[ ]+OPENSSH[ ]+PRIVATE[ ]+KEY`)},
|
||||
{"llave privada PEM", regexp.MustCompile(`(?i)BEGIN[ ]+(RSA|EC|DSA)?[ ]*PRIVATE[ ]+KEY`)},
|
||||
{"token GitHub", regexp.MustCompile(`\b(ghp|github_pat)_[A-Za-z0-9_]{20,}\b`)},
|
||||
{"access key AWS", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
|
||||
{"token Slack", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`)},
|
||||
{"contraseña explícita", regexp.MustCompile(`(?im)\b(password|passwd|contrase(?:ñ|n)a)\s*[:=]\s*[^<\s][^\n]{3,}`)},
|
||||
}
|
||||
|
||||
func scanSecrets(text string) []string {
|
||||
findings := []string{}
|
||||
for _, item := range secretPatterns {
|
||||
if item.Pattern.MatchString(text) {
|
||||
findings = append(findings, item.Name)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(findings)
|
||||
}
|
||||
|
||||
func tomlString(value string) string { return strconv.Quote(value) }
|
||||
|
||||
func tomlStringArray(values []string) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, tomlString(value))
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
func bullets(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "_No especificado._\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(&b, "- %s\n", value)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func codeBlock(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "_No se registraron comandos._\n"
|
||||
}
|
||||
return "```console\n" + strings.Join(values, "\n") + "\n```\n"
|
||||
}
|
||||
|
||||
func normalizeMarkdownBody(body string) string {
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
return strings.TrimSpace(body) + "\n"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readLines(r io.Reader) ([]string, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
var lines []string
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package clusterlog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlugifySpanishText(t *testing.T) {
|
||||
got := slugify(" Actualización del Clúster: Niño / ÁRBOL ")
|
||||
want := "actualizacion-del-cluster-nino-arbol"
|
||||
if got != want {
|
||||
t.Fatalf("slugify() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationMinutes(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"90": 90,
|
||||
"90m": 90,
|
||||
"2h": 120,
|
||||
"1h30m": 90,
|
||||
}
|
||||
for input, want := range tests {
|
||||
got, err := parseDurationMinutes(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDurationMinutes(%q): %v", input, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("parseDurationMinutes(%q) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := parseDurationMinutes("-5m"); err == nil {
|
||||
t.Fatal("expected negative duration to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSecrets(t *testing.T) {
|
||||
text := "password = supersecreto\n-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
findings := scanSecrets(text)
|
||||
if !contains(findings, "contraseña explícita") || !contains(findings, "llave privada OpenSSH") {
|
||||
t.Fatalf("unexpected findings: %#v", findings)
|
||||
}
|
||||
if findings := scanSecrets("kubectl get nodes\nshow version"); len(findings) != 0 {
|
||||
t.Fatalf("false positive findings: %#v", findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyFingerprintIsStable(t *testing.T) {
|
||||
key := testOpenSSHPublicKey(7, "ana@test")
|
||||
canonical1, fingerprint1, err := parsePublicKeyLine(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical2, fingerprint2, err := parsePublicKeyLine(" " + key + " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if canonical1 != canonical2 || fingerprint1 != fingerprint2 {
|
||||
t.Fatalf("key parsing is not stable: %q/%q vs %q/%q", canonical1, fingerprint1, canonical2, fingerprint2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testProjectRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mustWriteTestFile(t, filepath.Join(root, "zola.toml"), "title = \"test\"\n")
|
||||
for _, section := range []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, "content", section), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{
|
||||
filepath.Join(root, "data", reviewEventsDirName),
|
||||
filepath.Join(root, "data", taskEventsDirName),
|
||||
filepath.Join(root, "config"),
|
||||
} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
registry := AdminRegistry{
|
||||
Version: 1,
|
||||
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
|
||||
Admins: []Admin{},
|
||||
}
|
||||
if err := saveAdmins(root, registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testOpenSSHPublicKey(seed byte, comment string) string {
|
||||
var blob bytes.Buffer
|
||||
writeSSHString := func(value []byte) {
|
||||
_ = binary.Write(&blob, binary.BigEndian, uint32(len(value)))
|
||||
_, _ = blob.Write(value)
|
||||
}
|
||||
writeSSHString([]byte("ssh-ed25519"))
|
||||
key := bytes.Repeat([]byte{seed}, 32)
|
||||
writeSSHString(key)
|
||||
encoded := base64.StdEncoding.EncodeToString(blob.Bytes())
|
||||
if comment == "" {
|
||||
return "ssh-ed25519 " + encoded
|
||||
}
|
||||
return "ssh-ed25519 " + encoded + " " + comment
|
||||
}
|
||||
|
||||
func testAdmin(t *testing.T, id string, seed byte, roles ...string) Admin {
|
||||
t.Helper()
|
||||
publicKey := testOpenSSHPublicKey(seed, id+"@test")
|
||||
_, fingerprint, err := parsePublicKeyLine(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Admin{
|
||||
ID: id, Name: id, Email: id + "@example.test", Active: true, Roles: roles,
|
||||
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: publicKey, AddedAt: "2026-08-12T00:00:00Z"}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
const (
|
||||
ExitOK = 0
|
||||
ExitUsage = 2
|
||||
ExitIdentity = 3
|
||||
ExitValidation = 4
|
||||
ExitConflict = 5
|
||||
ExitGit = 6
|
||||
ExitInternal = 10
|
||||
)
|
||||
|
||||
type CLIError struct {
|
||||
Code int
|
||||
Kind string
|
||||
Message string
|
||||
Details any
|
||||
}
|
||||
|
||||
func (e *CLIError) Error() string { return e.Message }
|
||||
|
||||
func NewError(code int, kind, message string, details any) *CLIError {
|
||||
return &CLIError{Code: code, Kind: kind, Message: message, Details: details}
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Root string
|
||||
JSON bool
|
||||
SSHKey string
|
||||
Commit bool
|
||||
DryRun bool
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Stdin io.Reader
|
||||
Now func() time.Time
|
||||
Executable string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Options Options
|
||||
}
|
||||
|
||||
func New(options Options) *App {
|
||||
if options.Stdout == nil {
|
||||
options.Stdout = os.Stdout
|
||||
}
|
||||
if options.Stderr == nil {
|
||||
options.Stderr = os.Stderr
|
||||
}
|
||||
if options.Stdin == nil {
|
||||
options.Stdin = os.Stdin
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if options.Executable == "" {
|
||||
options.Executable = "clusterlog"
|
||||
}
|
||||
return &App{Options: options}
|
||||
}
|
||||
|
||||
type ReviewPolicy struct {
|
||||
Default string `json:"default"`
|
||||
IncludeAuthor bool `json:"include_author"`
|
||||
}
|
||||
|
||||
type SSHKey struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
AddedAt string `json:"added_at,omitempty"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
SSHKeys []SSHKey `json:"ssh_keys"`
|
||||
}
|
||||
|
||||
type AdminRegistry struct {
|
||||
Version int `json:"version"`
|
||||
ReviewPolicy ReviewPolicy `json:"review_policy"`
|
||||
Admins []Admin `json:"admins"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
Admin Admin `json:"admin"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PublicPath string `json:"public_key_path"`
|
||||
SigningKey string `json:"signing_key_path"`
|
||||
}
|
||||
|
||||
type ReviewEvent struct {
|
||||
ID string `json:"id"`
|
||||
EntryID string `json:"entry_id"`
|
||||
AdminID string `json:"admin_id"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ReviewedAt string `json:"reviewed_at"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type ReviewLog struct {
|
||||
Version int `json:"version"`
|
||||
Events []ReviewEvent `json:"events"`
|
||||
}
|
||||
|
||||
type TaskEvent struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Type string `json:"type"`
|
||||
AdminID string `json:"admin_id"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At string `json:"at"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Minutes int `json:"minutes,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type TaskLog struct {
|
||||
Version int `json:"version"`
|
||||
Events []TaskEvent `json:"events"`
|
||||
}
|
||||
|
||||
type PageMeta struct {
|
||||
Path string
|
||||
Body string
|
||||
Root map[string]any
|
||||
Taxonomies map[string][]string
|
||||
Extra map[string]any
|
||||
RawFront string
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
func (p PageMeta) String(key string) string {
|
||||
if v, ok := p.Root[key]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraString(key string) string {
|
||||
if v, ok := p.Extra[key]; ok {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraBool(key string) bool {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraInt(key string) int {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p PageMeta) ExtraStrings(key string) []string {
|
||||
v, ok := p.Extra[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch values := v.(type) {
|
||||
case []string:
|
||||
return values
|
||||
case []any:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
out = append(out, fmt.Sprint(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryRecord struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorFingerprint string `json:"author_fingerprint"`
|
||||
Risk string `json:"risk"`
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
ReviewPolicy string `json:"review_policy"`
|
||||
ReviewerSnapshot bool `json:"reviewer_snapshot"`
|
||||
Reviewers []string `json:"reviewers"`
|
||||
Tags []string `json:"tags"`
|
||||
Systems []string `json:"systems"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
}
|
||||
|
||||
type ReviewQueueItem struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Risk string `json:"risk"`
|
||||
Tags []string `json:"tags"`
|
||||
Systems []string `json:"systems"`
|
||||
Permalink string `json:"permalink"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
ReviewedBy []string `json:"reviewed_by"`
|
||||
MissingReviewers []string `json:"missing_reviewers"`
|
||||
RequiredCount int `json:"required_count"`
|
||||
ReviewedCount int `json:"reviewed_count"`
|
||||
ConfigurationError string `json:"configuration_error,omitempty"`
|
||||
}
|
||||
|
||||
type TaskState struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Assignees []string `json:"assignees"`
|
||||
EstimateMinutes int `json:"estimate_minutes"`
|
||||
ActualMinutes int `json:"actual_minutes"`
|
||||
Due string `json:"due,omitempty"`
|
||||
RunningBy []string `json:"running_by"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
}
|
||||
|
||||
type ProjectState struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Owners []string `json:"owners"`
|
||||
TargetDate string `json:"target_date,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Permalink string `json:"permalink"`
|
||||
SourcePath string `json:"source_path"`
|
||||
}
|
||||
|
||||
type SiteStats struct {
|
||||
ActiveAdmins int `json:"active_admins"`
|
||||
PendingReviews int `json:"pending_reviews"`
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
ActiveProjects int `json:"active_projects"`
|
||||
}
|
||||
|
||||
type SiteState struct {
|
||||
Version int `json:"version"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Stats SiteStats `json:"stats"`
|
||||
ReviewQueue []ReviewQueueItem `json:"review_queue"`
|
||||
RecentMemories []MemoryRecord `json:"recent_memories"`
|
||||
Tasks []TaskState `json:"tasks"`
|
||||
Projects []ProjectState `json:"projects"`
|
||||
}
|
||||
|
||||
type ResultEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) printResult(command string, data any, warnings ...string) error {
|
||||
if a.Options.JSON {
|
||||
enc := json.NewEncoder(a.Options.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings})
|
||||
}
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
fmt.Fprintln(a.Options.Stdout, v)
|
||||
default:
|
||||
bytes, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(a.Options.Stdout, string(bytes))
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrintError(w io.Writer, jsonMode bool, err error) int {
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
cliErr = NewError(ExitInternal, "internal", err.Error(), nil)
|
||||
}
|
||||
if jsonMode {
|
||||
payload := ResultEnvelope{OK: false, Error: map[string]any{
|
||||
"kind": cliErr.Kind,
|
||||
"message": cliErr.Message,
|
||||
"details": cliErr.Details,
|
||||
"exit_code": cliErr.Code,
|
||||
}}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
} else {
|
||||
fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message)
|
||||
if cliErr.Details != nil {
|
||||
bytes, _ := json.MarshalIndent(cliErr.Details, "", " ")
|
||||
fmt.Fprintln(w, string(bytes))
|
||||
}
|
||||
}
|
||||
return cliErr.Code
|
||||
}
|
||||
|
||||
func (a *App) ResolveRoot() (string, error) {
|
||||
if a.Options.Root != "" {
|
||||
root, err := filepath.Abs(a.Options.Root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil {
|
||||
return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user