bootstrap: iniciar bitácora operativa

This commit is contained in:
2026-08-13 00:43:15 -06:00
commit 1ecfb022b1
85 changed files with 7967 additions and 0 deletions
+361
View File
@@ -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
}