feat(motor): agregar version fijable en build y clusterlog bootstrap-instance

Primer paso hacia separar el motor (herramienta genérica) de cada
instancia (contenido de un deployment): Version deja de ser const para
poder inyectarse con -ldflags en `make build` (git describe, sin tags
aún), y el comando nuevo bootstrap-instance genera el esqueleto mínimo
de una instancia (zola.toml, content/<sección>/_index.md, admins.json
vacío, .clusterlog-version) reusando saveAdmins() en vez de duplicar su
formato. Documentado en CLI.md; el empaquetado de templates/tema y el
CI de instancia quedan para cuando el motor publique releases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:06:49 -06:00
co-authored by Claude Sonnet 5
parent c06037fa3b
commit 4afb85bb9e
16 changed files with 545 additions and 2 deletions
+39
View File
@@ -366,3 +366,42 @@ clusterlog --root . --json verify commit HEAD
``` ```
El agente debe detenerse ante cualquier código distinto de cero y presentar el error al usuario sin ocultarlo. El agente debe detenerse ante cualquier código distinto de cero y presentar el error al usuario sin ocultarlo.
## 16. Versión
```bash
clusterlog version
```
No requiere `--root` ni identidad. Devuelve `{"name":"clusterlog","version":"..."}`.
El valor de `version` se fija en tiempo de compilación:
```bash
go build -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0" ...
```
`make build` ya lo hace automáticamente a partir de `git describe --tags --always --dirty` (variable `CLUSTERLOG_VERSION` del `Makefile`); un build local sin tags produce algo como `abc1234-dirty`, no `dev` ni una cadena vacía.
## 17. Instancias nuevas
```bash
clusterlog bootstrap-instance \
--output ./clusterlog-nuevo-deploy \
--product-name "Bitácora de Ejemplo" \
--base-url "https://clusterlog.ejemplo.org"
```
No requiere `--root`: `--output` es un directorio nuevo, normalmente fuera del repositorio actual. Tampoco usa `--commit` (no hay nada que firmar todavía). Genera:
- `zola.toml` con `base_url`/`title`/`[extra] product_name` sustituidos y el resto de los valores genéricos del motor;
- `content/<sección>/_index.md` para `documentacion`, `manuales`, `memorias`, `proyectos`, `tareas` y `revision`;
- `data/admins.json` y `config/allowed_signers` vacíos (mismo formato que produce `admin add`, sin ningún administrador todavía);
- `data/review-events/.gitkeep`, `data/task-events/.gitkeep`, `data/generated/`;
- `.clusterlog-version` con la versión del binario que generó el scaffold;
- `README.md` y `.gitignore` de arranque.
Falla con `output_not_empty` (código `5`) si `--output` ya existe y no está vacío — nunca sobreescribe una instancia existente. Con `--dry-run` no crea el directorio; sólo muestra qué haría.
Después de `bootstrap-instance`, el flujo es el mismo que "Registrar al primer administrador" en `README.md`, apuntando `--root` al directorio nuevo: `admin add` → `sync` → `validate --skip-zola`. `validate` antes de la primera `sync` falla con `generated_state_stale` a propósito: la proyección todavía no existe.
**No incluido todavía** (fases posteriores, ver el ADR de motor/instancia cuando exista): plantillas Tera ni tema empaquetados para descarga, ni un `.gitea/workflows/ci.yml` de instancia — el motor aún no publica releases binarios.
+3 -1
View File
@@ -2,6 +2,8 @@ SHELL := /usr/bin/env bash
GO ?= go GO ?= go
ZOLA ?= zola ZOLA ?= zola
BINARY ?= bin/clusterlog BINARY ?= bin/clusterlog
CLUSTERLOG_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X example.org/bitacora-cluster/internal/clusterlog.Version=$(CLUSTERLOG_VERSION)
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci .PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci
@@ -9,7 +11,7 @@ all: build
build: build:
mkdir -p $(dir $(BINARY)) mkdir -p $(dir $(BINARY))
$(GO) build -trimpath -o $(BINARY) ./cmd/clusterlog $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/clusterlog
test: test:
$(GO) test ./... $(GO) test ./...
+10
View File
@@ -55,6 +55,16 @@ zola serve
El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa. El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa.
## Crear una instancia nueva
Este repositorio hoy mezcla la herramienta con el contenido de un deployment. Para empezar un deployment **distinto** sin partir de este contenido, use:
```bash
clusterlog bootstrap-instance --output ./mi-deploy --product-name "Mi Bitácora"
```
Genera `zola.toml`, las seis secciones de `content/` vacías, `data/admins.json`/`config/allowed_signers` vacíos y `.clusterlog-version`. Ver la sección 17 de [`CLI.md`](CLI.md) para el detalle completo; sigue siendo un primer paso — todavía no incluye plantillas/tema empaquetados ni CI de instancia (el motor no publica releases binarios todavía).
## Registrar al primer administrador ## Registrar al primer administrador
La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida. La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida.
+5
View File
@@ -24,6 +24,9 @@ func (a *App) Run(args []string) error {
if command == "schema" { if command == "schema" {
return a.runSchema(args[1:]) return a.runSchema(args[1:])
} }
if command == "bootstrap-instance" {
return a.runBootstrapInstance(args[1:])
}
root, err := a.ResolveRoot() root, err := a.ResolveRoot()
if err != nil { if err != nil {
@@ -66,6 +69,8 @@ Opciones globales (antes del comando):
--dry-run valida y muestra el resultado sin escribir --dry-run valida y muestra el resultado sin escribir
Comandos: Comandos:
version
bootstrap-instance --output DIR [--product-name NOMBRE] [--base-url URL]
identity identity
admin add | admin list admin add | admin list
memory create memory create
+170
View File
@@ -0,0 +1,170 @@
package clusterlog
import (
"embed"
"os"
"path/filepath"
"sort"
"strings"
)
//go:embed all:scaffold
var scaffoldFS embed.FS
const scaffoldRoot = "scaffold"
var scaffoldSections = []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"}
// runBootstrapInstance genera el esqueleto de una instancia nueva de
// clusterlog (content/, data/, config/, zola.toml) en --output. No toca el
// proyecto actual: --output es un directorio nuevo, casi siempre fuera de
// este repositorio, así que este comando no pasa por ResolveRoot ni por el
// mecanismo de --commit.
func (a *App) runBootstrapInstance(args []string) error {
fs := newFlagSet("bootstrap-instance")
output := fs.String("output", "", "directorio donde crear la instancia nueva")
productName := fs.String("product-name", "Bitácora Operativa", "nombre del producto para zola.toml")
baseURL := fs.String("base-url", "https://clusterlog.example.org", "base_url de zola.toml; cámbielo antes de publicar")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
if strings.TrimSpace(*output) == "" {
return NewError(ExitUsage, "missing_output", "indique --output con el directorio de la instancia nueva", nil)
}
root, err := filepath.Abs(*output)
if err != nil {
return NewError(ExitInternal, "internal", err.Error(), nil)
}
if entries, statErr := os.ReadDir(root); statErr == nil {
if len(entries) > 0 {
return NewError(ExitConflict, "output_not_empty", "el directorio de salida ya existe y no está vacío", root)
}
} else if !os.IsNotExist(statErr) {
return NewError(ExitInternal, "internal", statErr.Error(), root)
}
if a.Options.DryRun {
return a.printResult("bootstrap-instance", map[string]any{
"output": root,
"product_name": *productName,
"base_url": *baseURL,
"dry_run": true,
})
}
created, err := writeScaffold(root, *productName, *baseURL)
if err != nil {
return err
}
registry := AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{}}
if err := saveAdmins(root, registry); err != nil {
return NewError(ExitInternal, "internal", err.Error(), nil)
}
created = append(created,
filepath.Join(root, "data", "admins.json"),
filepath.Join(root, "config", "allowed_signers"),
)
sort.Strings(created)
return a.printResult("bootstrap-instance", map[string]any{
"output": root,
"engine_version": Version,
"product_name": *productName,
"base_url": *baseURL,
"files_created": created,
"next_steps": []string{
"clusterlog --root " + root + " --ssh-key <su-llave.pub> admin add --id <id> --name <nombre> --email <correo> --key <su-llave.pub> --roles owner,admin",
"clusterlog --root " + root + " sync",
"clusterlog --root " + root + " validate --skip-zola",
},
})
}
func writeScaffold(root, productName, baseURL string) ([]string, error) {
created := []string{}
for _, section := range scaffoldSections {
src := scaffoldRoot + "/content/" + section + "/_index.md"
data, err := scaffoldFS.ReadFile(src)
if err != nil {
return nil, NewError(ExitInternal, "internal", "plantilla de scaffold faltante: "+src, err.Error())
}
dst := filepath.Join(root, "content", section, "_index.md")
if err := writeFileAtomic(dst, data, 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), dst)
}
created = append(created, dst)
}
zolaTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/zola.toml.tmpl")
if err != nil {
return nil, NewError(ExitInternal, "internal", "plantilla zola.toml faltante", err.Error())
}
title := "Bitácora Operativa"
if strings.TrimSpace(productName) != "" {
title = productName
}
zolaContent := renderScaffoldTemplate(string(zolaTmpl), map[string]string{
"BASE_URL": baseURL,
"TITLE": title,
"PRODUCT_NAME": productName,
})
zolaPath := filepath.Join(root, "zola.toml")
if err := writeFileAtomic(zolaPath, []byte(zolaContent), 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), zolaPath)
}
created = append(created, zolaPath)
gitignoreTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/gitignore.tmpl")
if err != nil {
return nil, NewError(ExitInternal, "internal", "plantilla .gitignore faltante", err.Error())
}
gitignorePath := filepath.Join(root, ".gitignore")
if err := writeFileAtomic(gitignorePath, gitignoreTmpl, 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), gitignorePath)
}
created = append(created, gitignorePath)
readmeTmpl, err := scaffoldFS.ReadFile(scaffoldRoot + "/README.md.tmpl")
if err != nil {
return nil, NewError(ExitInternal, "internal", "plantilla README faltante", err.Error())
}
readmeContent := renderScaffoldTemplate(string(readmeTmpl), map[string]string{
"TITLE": title,
"PRODUCT_NAME": productName,
})
readmePath := filepath.Join(root, "README.md")
if err := writeFileAtomic(readmePath, []byte(readmeContent), 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), readmePath)
}
created = append(created, readmePath)
versionPath := filepath.Join(root, ".clusterlog-version")
if err := writeFileAtomic(versionPath, []byte(Version+"\n"), 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), versionPath)
}
created = append(created, versionPath)
for _, dir := range []string{filepath.Join(root, "data", "review-events"), filepath.Join(root, "data", "task-events")} {
keep := filepath.Join(dir, ".gitkeep")
if err := writeFileAtomic(keep, []byte{}, 0o644); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), keep)
}
created = append(created, keep)
}
if err := os.MkdirAll(filepath.Join(root, "data", "generated"), 0o755); err != nil {
return nil, NewError(ExitInternal, "internal", err.Error(), nil)
}
return created, nil
}
func renderScaffoldTemplate(text string, values map[string]string) string {
for key, value := range values {
text = strings.ReplaceAll(text, "{{"+key+"}}", value)
}
return text
}
@@ -0,0 +1,149 @@
package clusterlog
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func newBootstrapApp(t *testing.T, dryRun bool) *App {
t.Helper()
return New(Options{DryRun: dryRun, JSON: true, Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog"})
}
func TestBootstrapInstanceCreatesExpectedFiles(t *testing.T) {
base := t.TempDir()
out := filepath.Join(base, "instancia-nueva")
app := newBootstrapApp(t, false)
err := app.runBootstrapInstance([]string{"--output", out, "--product-name", "Bitácora de Prueba", "--base-url", "https://clusterlog.prueba.org"})
if err != nil {
t.Fatalf("runBootstrapInstance falló: %v", err)
}
for _, section := range scaffoldSections {
path := filepath.Join(out, "content", section, "_index.md")
if _, statErr := os.Stat(path); statErr != nil {
t.Fatalf("falta %s: %v", path, statErr)
}
}
zolaData, err := os.ReadFile(filepath.Join(out, "zola.toml"))
if err != nil {
t.Fatal(err)
}
zola := string(zolaData)
if !strings.Contains(zola, `base_url = "https://clusterlog.prueba.org"`) {
t.Fatalf("zola.toml no tiene el base_url esperado: %s", zola)
}
if !strings.Contains(zola, `title = "Bitácora de Prueba"`) {
t.Fatalf("zola.toml no tiene el title esperado: %s", zola)
}
if !strings.Contains(zola, `product_name = "Bitácora de Prueba"`) {
t.Fatalf("zola.toml no tiene product_name esperado: %s", zola)
}
var registry AdminRegistry
adminsData, err := os.ReadFile(filepath.Join(out, "data", "admins.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(adminsData, &registry); err != nil {
t.Fatal(err)
}
if registry.Version != 1 || len(registry.Admins) != 0 || registry.ReviewPolicy.Default != "all-active" {
t.Fatalf("registro de admins inesperado: %+v", registry)
}
if _, err := os.Stat(filepath.Join(out, "config", "allowed_signers")); err != nil {
t.Fatalf("falta config/allowed_signers: %v", err)
}
versionData, err := os.ReadFile(filepath.Join(out, ".clusterlog-version"))
if err != nil {
t.Fatal(err)
}
if trimmed := strings.TrimSpace(string(versionData)); trimmed != Version {
t.Fatalf(".clusterlog-version = %q, se esperaba %q", trimmed, Version)
}
for _, dir := range []string{"review-events", "task-events"} {
if _, err := os.Stat(filepath.Join(out, "data", dir, ".gitkeep")); err != nil {
t.Fatalf("falta data/%s/.gitkeep: %v", dir, err)
}
}
if _, err := os.Stat(filepath.Join(out, "README.md")); err != nil {
t.Fatalf("falta README.md: %v", err)
}
if _, err := os.Stat(filepath.Join(out, ".gitignore")); err != nil {
t.Fatalf("falta .gitignore: %v", err)
}
}
func TestBootstrapInstanceScaffoldValidatesClean(t *testing.T) {
base := t.TempDir()
out := filepath.Join(base, "instancia-nueva")
app := newBootstrapApp(t, false)
if err := app.runBootstrapInstance([]string{"--output", out}); err != nil {
t.Fatalf("runBootstrapInstance falló: %v", err)
}
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
validateApp := &App{}
registry, err := loadAdmins(out)
if err != nil {
t.Fatal(err)
}
validateApp.validateAdmins(out, registry, &report)
pages := validateApp.validatePages(out, registry, &report)
validateApp.validateEvents(out, registry, pages, &report)
if len(report.Errors) != 0 {
t.Fatalf("el scaffold recién creado no debería tener errores de validate: %+v", report.Errors)
}
}
func TestBootstrapInstanceRefusesNonEmptyOutput(t *testing.T) {
base := t.TempDir()
out := filepath.Join(base, "instancia-existente")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, filepath.Join(out, "algo.txt"), "ya había algo aquí")
app := newBootstrapApp(t, false)
err := app.runBootstrapInstance([]string{"--output", out})
if err == nil {
t.Fatal("se esperaba un error por directorio no vacío")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Kind != "output_not_empty" {
t.Fatalf("se esperaba output_not_empty, se obtuvo: %v", err)
}
}
func TestBootstrapInstanceDryRunWritesNothing(t *testing.T) {
base := t.TempDir()
out := filepath.Join(base, "instancia-dry-run")
app := newBootstrapApp(t, true)
if err := app.runBootstrapInstance([]string{"--output", out}); err != nil {
t.Fatalf("runBootstrapInstance --dry-run falló: %v", err)
}
if _, err := os.Stat(out); err == nil {
t.Fatal("--dry-run no debería haber creado el directorio de salida")
}
}
func TestBootstrapInstanceRequiresOutput(t *testing.T) {
app := newBootstrapApp(t, false)
err := app.runBootstrapInstance([]string{})
if err == nil {
t.Fatal("se esperaba un error por --output faltante")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Kind != "missing_output" {
t.Fatalf("se esperaba missing_output, se obtuvo: %v", err)
}
}
@@ -0,0 +1,51 @@
# {{TITLE}}
Este repositorio es una **instancia de clusterlog**: contiene el contenido,
los datos y la configuración de un deployment concreto, no el código del
motor. Se generó con:
```bash
clusterlog bootstrap-instance --output . --product-name "{{PRODUCT_NAME}}"
```
## Qué hay aquí
- `content/` — documentación, manuales, memorias, proyectos y tareas de esta
instancia. Empieza con las seis secciones vacías (`_index.md` únicamente).
- `data/admins.json`, `config/allowed_signers` — registro de administradores,
vacío hasta el primer `admin add`.
- `zola.toml` — configuración de este deployment (`base_url`, nombre del
producto). Cámbiela antes de publicar.
- `.clusterlog-version` — versión del motor `clusterlog` usada para generar
este esqueleto. Manténgala sincronizada con el binario que use su CI.
## Primeros pasos
1. Cambie `base_url` en `zola.toml` por el dominio real de este deployment.
2. Registre al primer administrador (recibe `owner` automáticamente):
```bash
clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub admin add \
--id ana --name "Ana Torres" --email ana@example.org \
--key ~/.ssh/id_ed25519.pub --roles owner,admin
```
3. Inicialice Git y firme el primer commit (vea la sección "Registrar al
primer administrador" en la documentación del motor para el detalle
completo de `git config`/`commit -S`).
4. Genere la proyección y valide:
```bash
clusterlog --root . sync
clusterlog --root . validate --skip-zola
```
## Pendiente (no incluido por este comando)
- Plantillas Tera y el tema `zola.386`: todavía se obtienen desde un checkout
del repositorio del motor (`make theme` / `scripts/bootstrap-theme.sh`).
Empaquetarlos en un release descargable del motor es trabajo de una fase
posterior.
- Un workflow de CI para esta instancia (que descargue el binario del motor
por versión fijada) no se generó todavía: el motor aún no publica releases
binarios. Se agrega en cuanto exista ese release.
@@ -0,0 +1,9 @@
+++
title = "Documentación"
description = "Arquitectura, decisiones, inventarios, políticas y explicaciones técnicas duraderas."
sort_by = "weight"
template = "section.html"
page_template = "page.html"
+++
Aquí vive el conocimiento descriptivo del clúster: el **qué**, el **por qué** y las relaciones entre componentes.
@@ -0,0 +1,9 @@
+++
title = "Manuales"
description = "Runbooks y procedimientos operativos verificables, con prerrequisitos, pasos y reversión."
sort_by = "weight"
template = "section.html"
page_template = "page.html"
+++
Los manuales deben ser ejecutables por una persona distinta de quien los escribió y declarar claramente sus condiciones de seguridad.
@@ -0,0 +1,10 @@
+++
title = "Memorias"
description = "Historia cronológica y auditable de intervenciones, cambios, incidentes y correcciones."
sort_by = "date"
template = "memories.html"
page_template = "memory.html"
generate_feeds = true
+++
Cada entrada describe una sesión de trabajo o una unidad de cambio. Las entradas sujetas a revisión aparecen también en **Revisión semanal**.
@@ -0,0 +1,9 @@
+++
title = "Proyectos"
description = "Objetivos compartidos, alcance, responsables, hitos y criterios de éxito."
sort_by = "date"
template = "projects.html"
page_template = "project.html"
+++
Los proyectos dan contexto a las tareas y permiten explicar por qué existe cada línea de trabajo.
@@ -0,0 +1,8 @@
+++
title = "Revisión semanal"
description = "Memorias que aún requieren confirmación de uno o más administradores."
template = "review.html"
in_search_index = false
+++
Esta página es la agenda operativa de la reunión semanal. Una memoria desaparece cuando todos los revisores requeridos han registrado su revisión para el hash actual del contenido.
@@ -0,0 +1,9 @@
+++
title = "Tareas"
description = "Trabajo visible para el equipo, con prioridad, estimación, fecha objetivo y tiempo acumulado."
sort_by = "date"
template = "tasks.html"
page_template = "task.html"
+++
El estado actual y el tiempo real se calculan a partir de eventos anexables; la definición original de la tarea permanece legible en Markdown.
@@ -0,0 +1,5 @@
/public/
/bin/
/themes/zola.386/
.DS_Store
*.tmp
@@ -0,0 +1,53 @@
base_url = "{{BASE_URL}}"
title = "{{TITLE}}"
description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte."
default_language = "es"
theme = "zola.386"
output_dir = "public"
compile_sass = true
minify_html = true
generate_feeds = true
feed_filenames = ["atom.xml", "rss.xml"]
build_search_index = true
generate_sitemap = true
generate_robots_txt = false
taxonomies = [
{ name = "categories" },
{ name = "tags", feed = true },
{ name = "systems" },
]
[markdown]
external_links_target_blank = true
external_links_no_referrer = true
smart_punctuation = true
github_alerts = true
insert_anchor_links = "right"
[markdown.highlighting]
error_on_missing_language = false
style = "inline"
theme = "vitesse-black"
[search]
include_title = true
include_description = true
include_date = true
include_path = true
include_content = true
truncate_content_length = 12000
index_format = "elasticlunr_json"
[slugify]
paths = "on"
taxonomies = "on"
anchors = "on"
paths_keep_dates = false
[extra]
product_name = "{{PRODUCT_NAME}}"
product_short_name = "clusterlog"
repository_label = "Repositorio interno"
review_meeting_label = "Revisión semanal"
footer_text = "Fuente auditable: Markdown + eventos JSON + Git"
+6 -1
View File
@@ -10,7 +10,12 @@ import (
"time" "time"
) )
const Version = "0.1.0" // Version se puede sobreescribir en tiempo de build con:
//
// -ldflags "-X example.org/bitacora-cluster/internal/clusterlog.Version=v0.2.0"
//
// Ver la variable CLUSTERLOG_VERSION en el Makefile.
var Version = "0.1.0"
const ( const (
ExitOK = 0 ExitOK = 0