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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user