Compare commits
38
Commits
62087924c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5bed22c2 | ||
|
|
38c04ca62f | ||
|
|
064b5d5bb8 | ||
|
|
f178ecdcc7 | ||
|
|
63970a89e1 | ||
|
|
622578b454 | ||
|
|
eac5e22ed1 | ||
|
|
5240cfd8c2 | ||
|
|
4afb85bb9e | ||
|
|
c06037fa3b | ||
|
|
fcee75fe83 | ||
|
|
e6c9733725 | ||
|
|
3bf89b9888 | ||
|
|
2c13427788 | ||
|
|
50999ef423 | ||
|
|
14b7d0687a | ||
|
|
15ee7a7acc | ||
|
|
7242dd78e7 | ||
|
|
cf8817f049 | ||
|
|
5dbf21c932 | ||
|
|
08b126c054 | ||
|
|
f91a590a82 | ||
|
|
856995db1b | ||
|
|
4dbdc15988 | ||
|
|
f638a9e314 | ||
|
|
a5644be3fc | ||
|
|
e7d2ad374a | ||
|
|
a6cdee023c | ||
|
|
79ab715d5d | ||
|
|
2446c1ccfa | ||
|
|
c70c44ca7f | ||
|
|
18e614e29c | ||
|
|
944158c240 | ||
|
|
57f347a259 | ||
|
|
334f386589 | ||
|
|
94cf529420 | ||
|
|
a086df8f13 | ||
|
|
3de89b8dd8 |
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: acceso-cluster
|
||||||
|
description: Cómo conectarse por SSH al clúster que este equipo administra (host mcps y otros que se agreguen) desde cualquier sesión de IA lanzada en este repositorio. Úsala siempre que una tarea requiera ejecutar comandos en el clúster, diagnosticar algo en vivo, o cuando alguien pida conectarse a un host administrado.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acceso SSH al clúster administrado
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
Esta skill documenta cómo llegar al **clúster** que el comité administra (el host, no la herramienta). Es la contraparte operativa de [`docs/ADR.md`](../../../docs/ADR.md): las decisiones de diseño de `clusterlog` (la herramienta) están en `docs/adr/`; el conocimiento de cómo alcanzar y tratar el clúster real vive aquí y en el manual asociado.
|
||||||
|
|
||||||
|
Se carga automáticamente en cualquier sesión de Claude Code iniciada en este repositorio, sin importar qué miembro del comité la lance, para que todas las IAs usen el mismo contrato de conexión.
|
||||||
|
|
||||||
|
## Contrato de conexión (obligatorio)
|
||||||
|
|
||||||
|
- **Nunca construya la conexión a mano.** No arme comandos del tipo `ssh -i ~/.ssh/<llave> usuario@ip`. Use siempre el alias corto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh <alias>
|
||||||
|
```
|
||||||
|
|
||||||
|
donde `<alias>` es uno de los definidos en el [inventario](#inventario-de-hosts) más abajo (por ejemplo, `ssh mcps`).
|
||||||
|
|
||||||
|
- **La identidad (llave, usuario, host real, puerto) nunca vive en este repositorio.** Cada administrador la resuelve en su propio `~/.ssh/config`, local y no versionado. Ese archivo es lo único que traduce el alias compartido a los datos personales de conexión. Ver [el manual de configuración](../../../content/manuales/) para la plantilla.
|
||||||
|
- Si un alias no existe o la conexión falla, **no adivine** IP, usuario ni ruta de llave. Dígale al usuario qué alias faltó y remítalo al manual de configuración; no intente "resolverlo" con valores inventados.
|
||||||
|
- **Nunca lea, imprima, copie ni pegue el contenido de una llave privada**, ni siquiera para depurar. `ssh -v <alias>` es suficiente para diagnosticar sin exponer material sensible.
|
||||||
|
- Antes de ejecutar cualquier comando contra un host real, **anuncie qué va a correr**. Prefiera primero comandos de sólo lectura/diagnóstico (`uptime`, `systemctl status`, `df -h`, logs) sobre cualquier cosa que cambie estado.
|
||||||
|
- **Nunca ejecute un comando destructivo o que cambie estado en el clúster sin confirmación explícita del usuario en esa sesión**, aunque parezca una tarea rutinaria. Esto aplica con más fuerza que el nivel de autonomía general de la sesión, porque el radio de impacto es un sistema compartido, no este repositorio.
|
||||||
|
- Si la sesión hizo un cambio real en el clúster, ese cambio se documenta como una memoria de `clusterlog` (ver `AGENTS.md` / `CLAUDE.md`) — esta skill sólo gobierna *cómo llegar*, no cómo registrar lo que se hizo.
|
||||||
|
|
||||||
|
## Inventario de hosts
|
||||||
|
|
||||||
|
Tabla compartida y sin secretos: alias, para qué sirve, y notas de riesgo. **No incluya aquí IP, usuario, puerto ni ruta de llave** — eso es personal y va en el `~/.ssh/config` de cada quien.
|
||||||
|
|
||||||
|
| Alias | Rol | Notas | Riesgo |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mcps` | Host/entrada principal del clúster administrado. | El mismo acceso se reutiliza para el servicio Git interno (`git.mcps-epcm.org:2222`); ver [manual de acceso a Git](../../../content/manuales/2026-08-18-run-20260818-ba0a96-configurar-acceso-al-servicio-git.md). | Por confirmar con el comité. |
|
||||||
|
|
||||||
|
Cuando el clúster incorpore un nuevo host o rol relevante, agregue una fila aquí en un pull request normal — esta tabla es la fuente compartida de qué alias existen y para qué sirve cada uno. Cada administrador, por su parte, agrega el bloque `Host` correspondiente en su propio `~/.ssh/config` (procedimiento en el manual).
|
||||||
|
|
||||||
|
## División de responsabilidades
|
||||||
|
|
||||||
|
| Vive en | Contiene | Versionado |
|
||||||
|
|---|---|---|
|
||||||
|
| Esta skill (`.claude/skills/acceso-cluster/`) | Contrato de comportamiento + inventario de alias/roles, sin secretos. | Sí, compartido. |
|
||||||
|
| `~/.ssh/config` de cada administrador | Host real, usuario, puerto, ruta de llave por alias. | No, local y personal. |
|
||||||
|
| [Manual de configuración](../../../content/manuales/) | Procedimiento paso a paso para que una persona arme su `~/.ssh/config` por primera vez. | Sí, compartido (el procedimiento; no los valores personales). |
|
||||||
|
|
||||||
|
Si al operar el clúster aparece un dato de conexión que no encaja en esta división (por ejemplo, un salto por bastión, un puerto no estándar), decídalo con el comité y refléjelo en el inventario de arriba y en la plantilla del manual — no lo resuelva ad hoc dentro de una sesión.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
v0.2.0
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.go]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[Makefile]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Copie a .env (ignorado por git) si su flujo local o Runme lo necesita.
|
||||||
|
# Nunca ponga valores reales de credenciales en este archivo ni en .env.
|
||||||
|
|
||||||
|
# Raíz del proyecto clusterlog, si no se ejecuta desde ahí.
|
||||||
|
CLUSTERLOG_ROOT=.
|
||||||
|
|
||||||
|
# Ruta a SU llave pública (o base) para identidad/firma. Nunca la ruta de la
|
||||||
|
# llave privada de otra persona, y nunca el contenido de una llave privada.
|
||||||
|
CLUSTERLOG_SSH_KEY=
|
||||||
|
|
||||||
|
# Entorno lógico para runbooks que necesiten distinguir local/staging/prod.
|
||||||
|
# Valores esperados: local, ci, staging, production.
|
||||||
|
CLUSTERLOG_ENV=local
|
||||||
|
|
||||||
|
# Ruta a un archivo JSON de memoria ya redactado (sin secretos), usado por
|
||||||
|
# el flujo de ejemplo de "memory create --from-json" en AGENTS.md.
|
||||||
|
CLUSTERLOG_MEMORY_JSON=
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Instalar Zola fijado
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
curl -sL "https://github.com/getzola/zola/releases/download/v0.23.3/zola-v0.23.3-x86_64-unknown-linux-gnu.tar.gz" -o /tmp/zola.tar.gz
|
||||||
|
tar xz -C "$HOME/.local/bin" -f /tmp/zola.tar.gz zola
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
"$HOME/.local/bin/zola" --version
|
||||||
|
|
||||||
|
- name: Preparar tema fijado
|
||||||
|
run: ./scripts/bootstrap-theme.sh
|
||||||
|
|
||||||
|
- name: Descargar plantillas y binario del motor (fenandosr/clusterlog, versión fijada en .clusterlog-version)
|
||||||
|
run: |
|
||||||
|
make templates
|
||||||
|
make build
|
||||||
|
|
||||||
|
- name: Verificar firmas SSH de commits del pull request
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: ./scripts/verify-signatures-ci.sh
|
||||||
|
|
||||||
|
- name: Regenerar y comprobar proyección
|
||||||
|
run: |
|
||||||
|
./bin/clusterlog --root . sync
|
||||||
|
./bin/clusterlog --root . sync --check
|
||||||
|
|
||||||
|
- name: Validar contenido y plantillas
|
||||||
|
run: ./bin/clusterlog --root . validate --require-zola
|
||||||
|
|
||||||
|
- name: Construir y, en main, publicar en producción
|
||||||
|
run: |
|
||||||
|
zola build
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ] && [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
echo "Publicando en clusterlog.mcps-epcm.org"
|
||||||
|
rsync -a --delete public/ /opt/clusterlog/public/
|
||||||
|
else
|
||||||
|
echo "Rama '${{ github.ref_name }}' (evento '${{ github.event_name }}'): no se publica."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Fin del pipeline
|
||||||
|
run: echo "listo"
|
||||||
|
|
||||||
|
runme:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
DO_NOT_TRACK: "true"
|
||||||
|
SCARF_NO_ANALYTICS: "true"
|
||||||
|
CI: "true"
|
||||||
|
TERM: "dumb"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Descargar binario del motor (versión fijada en .clusterlog-version)
|
||||||
|
run: make build
|
||||||
|
|
||||||
|
# source_sha256 de data/generated/site_state.json incluye la ruta
|
||||||
|
# absoluta del checkout, así que difiere entre máquinas aunque el
|
||||||
|
# contenido sea idéntico; hay que regenerarlo en este mismo checkout
|
||||||
|
# antes de validar, igual que ya hace el job validate-and-build.
|
||||||
|
- name: Regenerar la proyección en este checkout
|
||||||
|
run: ./bin/clusterlog --root . sync
|
||||||
|
|
||||||
|
- name: Instalar Runme fijado (v3.17.4, checksum verificado)
|
||||||
|
run: make runme-install
|
||||||
|
|
||||||
|
- name: Exponer runme en PATH
|
||||||
|
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Reglas de clusterlog validate sobre los runbooks
|
||||||
|
run: make runme-check
|
||||||
|
|
||||||
|
- name: Ejecutar celdas ci-safe de RUNBOOK.md
|
||||||
|
run: make runme-ci
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-and-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Instalar Zola fijado
|
|
||||||
uses: taiki-e/install-action@v2
|
|
||||||
with:
|
|
||||||
tool: zola@0.23.3
|
|
||||||
fallback: none
|
|
||||||
|
|
||||||
- name: Preparar tema fijado
|
|
||||||
run: ./scripts/bootstrap-theme.sh
|
|
||||||
|
|
||||||
- name: Formato Go
|
|
||||||
run: test -z "$(gofmt -l cmd internal)"
|
|
||||||
|
|
||||||
- name: Pruebas Go
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Detector de carreras Go
|
|
||||||
run: go test -race ./...
|
|
||||||
|
|
||||||
- name: Análisis estático Go
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: Compilar CLI
|
|
||||||
run: make build
|
|
||||||
|
|
||||||
- name: Flujo integral de la CLI
|
|
||||||
run: ./scripts/e2e-local.sh
|
|
||||||
|
|
||||||
- name: Verificar firmas SSH de commits del pull request
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
env:
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: ./scripts/verify-signatures-ci.sh
|
|
||||||
|
|
||||||
- name: Regenerar y comprobar proyección
|
|
||||||
run: |
|
|
||||||
./bin/clusterlog --root . sync
|
|
||||||
./bin/clusterlog --root . sync --check
|
|
||||||
|
|
||||||
- name: Validar contenido y plantillas
|
|
||||||
run: ./bin/clusterlog --root . validate --require-zola
|
|
||||||
|
|
||||||
- name: Construir sitio
|
|
||||||
run: zola build
|
|
||||||
+14
@@ -1,5 +1,19 @@
|
|||||||
|
/memorias/
|
||||||
/public/
|
/public/
|
||||||
/bin/
|
/bin/
|
||||||
|
/templates/
|
||||||
/themes/zola.386/
|
/themes/zola.386/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Runme: variables de entorno locales, nunca valores reales versionados.
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
# No hay un patrón fiable de Session Outputs de Runme para v3.17.4 (ver
|
||||||
|
# docs/RUNME.md en el repositorio del motor): un glob amplio aquí podría
|
||||||
|
# ocultar Markdown legítimo por accidente. clusterlog validate detecta
|
||||||
|
# señales concretas en su lugar (directorio .runme/ versionado, archivos
|
||||||
|
# "*output*" dentro de runbooks/).
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["stateful.runme"]
|
||||||
|
}
|
||||||
@@ -72,15 +72,33 @@ No convierta salidas extensas de terminal en el cuerpo. Resuma y enlace evidenci
|
|||||||
- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse.
|
- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse.
|
||||||
- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos.
|
- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos.
|
||||||
|
|
||||||
## Cambios al código del proyecto
|
## Runbooks ejecutables (Runme)
|
||||||
|
|
||||||
Antes de entregar:
|
`RUNBOOK.md` y `runbooks/**/*.md` son documentación ejecutable, no una
|
||||||
|
segunda CLI. Reglas para un agente:
|
||||||
|
|
||||||
|
- Nunca active una celda `risk-mutating` o `risk-destructive` sin que el
|
||||||
|
usuario haya autorizado explícitamente esa operación puntual — la
|
||||||
|
etiqueta `manual-only`/`interactive`/`excludeFromRunAll` es una defensa
|
||||||
|
técnica de Runme, no una autorización.
|
||||||
|
- Trate cada celda como el comando que envuelve: si el comando requiere
|
||||||
|
`--dry-run` antes de `--commit`, la celda también.
|
||||||
|
- No agregue lógica nueva dentro de una celda; si hace falta lógica
|
||||||
|
reutilizable, colóquela en `clusterlog`, un script en `scripts/` o un
|
||||||
|
target de `Makefile`, y haga que la celda sólo lo invoque.
|
||||||
|
- No genere ni edite manualmente un identificador Lifecycle Identity de
|
||||||
|
Runme.
|
||||||
|
|
||||||
|
## Cambios a esta instancia
|
||||||
|
|
||||||
|
Este repositorio no tiene código de la CLI (vive en
|
||||||
|
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog)). Un cambio
|
||||||
|
aquí es Markdown/JSON de contenido o configuración de deployment. Antes de
|
||||||
|
entregar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gofmt -w cmd internal
|
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
make build
|
make build
|
||||||
|
make templates
|
||||||
./bin/clusterlog --root . sync
|
./bin/clusterlog --root . sync
|
||||||
./bin/clusterlog --root . sync --check
|
./bin/clusterlog --root . sync --check
|
||||||
./bin/clusterlog --root . validate --skip-zola
|
./bin/clusterlog --root . validate --skip-zola
|
||||||
@@ -94,4 +112,5 @@ Cuando Zola esté disponible:
|
|||||||
zola build
|
zola build
|
||||||
```
|
```
|
||||||
|
|
||||||
Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas.
|
Si el cambio necesario es a la CLI, sus esquemas o su comportamiento, hágalo
|
||||||
|
en el repositorio del motor, no aquí.
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 0.1.0 — 2026-08-12
|
|
||||||
|
|
||||||
- Estructura Zola con Documentación, Manuales, Memorias, Proyectos, Tareas y Revisión.
|
|
||||||
- CLI `clusterlog` orientada a humanos y agentes.
|
|
||||||
- Identidad por huella SSH y commits firmados opcionales.
|
|
||||||
- Revisión por hash y conjunto capturado de administradores.
|
|
||||||
- Eventos independientes para revisiones y tiempo de tareas.
|
|
||||||
- Proyección regenerable para el sitio.
|
|
||||||
- Validación, escaneo heurístico de secretos, pruebas y CI.
|
|
||||||
- Estrategia de compatibilidad fijada para `zola.386`.
|
|
||||||
@@ -23,6 +23,12 @@ Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de admini
|
|||||||
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
|
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
|
||||||
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
|
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
|
||||||
|
|
||||||
|
## Runbooks ejecutables (Runme)
|
||||||
|
|
||||||
|
- No actives una celda `risk-mutating`/`risk-destructive` de `RUNBOOK.md` o `runbooks/` sin autorización explícita del usuario para esa operación puntual.
|
||||||
|
- Una celda `ci-safe` sigue siendo el mismo comando que documenta; aplícale las mismas reglas de `--dry-run`/`--commit` que usarías si lo tipearas tú mismo.
|
||||||
|
- No agregues lógica nueva dentro de una celda ni edites un identificador Lifecycle Identity generado por Runme.
|
||||||
|
|
||||||
## Formato de respuesta al usuario
|
## Formato de respuesta al usuario
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
# Referencia de `clusterlog`
|
|
||||||
|
|
||||||
## 1. Forma general
|
|
||||||
|
|
||||||
```text
|
|
||||||
clusterlog [opciones globales] <comando> [opciones del comando]
|
|
||||||
```
|
|
||||||
|
|
||||||
Las opciones globales deben colocarse **antes** del comando.
|
|
||||||
|
|
||||||
| Opción | Efecto |
|
|
||||||
|---|---|
|
|
||||||
| `--root DIR` | raíz del repositorio; si se omite, la CLI busca el proyecto desde el directorio actual |
|
|
||||||
| `--json` | salida estructurada y estable para agentes |
|
|
||||||
| `--ssh-key PATH` | llave pública o ruta base usada para resolver identidad y firma |
|
|
||||||
| `--commit` | crea un commit Git SSH firmado con sólo los archivos de la operación |
|
|
||||||
| `--dry-run` | valida y calcula el resultado sin escribir |
|
|
||||||
|
|
||||||
También puede definirse `CLUSTERLOG_SSH_KEY`.
|
|
||||||
|
|
||||||
## 2. Contrato JSON
|
|
||||||
|
|
||||||
Éxito:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"command": "memory.create",
|
|
||||||
"data": {},
|
|
||||||
"warnings": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Error:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": false,
|
|
||||||
"error": {
|
|
||||||
"kind": "identity_not_found",
|
|
||||||
"message": "ninguna llave SSH local coincide con un administrador activo",
|
|
||||||
"details": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Un agente debe usar el código del proceso como decisión primaria y `error.kind` para tratamiento específico. No debe inferir éxito por texto parcial.
|
|
||||||
|
|
||||||
## 3. Códigos de salida
|
|
||||||
|
|
||||||
| Código | Significado |
|
|
||||||
|---:|---|
|
|
||||||
| `0` | éxito |
|
|
||||||
| `2` | uso, flags o entrada inválida |
|
|
||||||
| `3` | identidad no encontrada o no autorizada |
|
|
||||||
| `4` | validación, firma o contenido inválido |
|
|
||||||
| `5` | conflicto de estado |
|
|
||||||
| `6` | operación Git fallida |
|
|
||||||
| `10` | error interno no clasificado |
|
|
||||||
|
|
||||||
## 4. Identidad
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub identity
|
|
||||||
```
|
|
||||||
|
|
||||||
La respuesta incluye el administrador, la huella, la ruta pública y la ruta que Git intentará usar para firmar. No prueba posesión hasta que exista una firma válida.
|
|
||||||
|
|
||||||
## 5. Administradores
|
|
||||||
|
|
||||||
### Agregar
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
Reglas:
|
|
||||||
|
|
||||||
- El primer administrador obtiene `owner` aunque no se incluya explícitamente.
|
|
||||||
- Después del primero, sólo un `owner` puede agregar administradores.
|
|
||||||
- No se aceptan IDs, correos o huellas duplicados.
|
|
||||||
- `data/admins.json` y `config/allowed_signers` se actualizan juntos.
|
|
||||||
|
|
||||||
### Listar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json admin list
|
|
||||||
clusterlog --root . --json admin list --active
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Memorias
|
|
||||||
|
|
||||||
### Consultar esquema
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --json schema memory
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crear por JSON
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat examples/memory.json | clusterlog \
|
|
||||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
memory create --from-json -
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crear por flags
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub memory create \
|
|
||||||
--title "Rotar certificado del API" \
|
|
||||||
--description "Renovación programada y comprobación de clientes" \
|
|
||||||
--tags tls,certificados \
|
|
||||||
--systems kube-apiserver \
|
|
||||||
--risk medium \
|
|
||||||
--ticket CHG-1042 \
|
|
||||||
--duration 45m \
|
|
||||||
--agent claude-code \
|
|
||||||
--body-file memoria.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Opciones específicas:
|
|
||||||
|
|
||||||
- `--title`
|
|
||||||
- `--description`
|
|
||||||
- `--tags`
|
|
||||||
- `--systems`
|
|
||||||
- `--risk low|medium|high|critical`
|
|
||||||
- `--ticket`
|
|
||||||
- `--review` (por defecto `true`)
|
|
||||||
- `--review-policy all-active|all-active-except-author`
|
|
||||||
- `--duration` en minutos, `90m` o `2h`
|
|
||||||
- `--agent`
|
|
||||||
- `--body-file ARCHIVO|-`
|
|
||||||
- `--from-json ARCHIVO|-`
|
|
||||||
|
|
||||||
Cuando se combinan JSON y flags, los flags que se proporcionen explícitamente tienen precedencia.
|
|
||||||
|
|
||||||
## 7. Revisión
|
|
||||||
|
|
||||||
### Listar pendientes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json review list
|
|
||||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
|
|
||||||
```
|
|
||||||
|
|
||||||
`--mine` requiere identidad y devuelve sólo memorias cuyo conjunto pendiente contiene al administrador actual.
|
|
||||||
|
|
||||||
### Marcar la versión actual
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
review mark MEM-20260812-... --note "Revisado; sin objeciones"
|
|
||||||
```
|
|
||||||
|
|
||||||
El evento guarda el SHA-256 del contenido actual. Repetir el comando sobre el mismo hash es idempotente; editar la memoria hace que el evento anterior deje de contar.
|
|
||||||
|
|
||||||
## 8. Tareas
|
|
||||||
|
|
||||||
### Crear
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat examples/task.json | clusterlog \
|
|
||||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task create --from-json -
|
|
||||||
```
|
|
||||||
|
|
||||||
Flags disponibles:
|
|
||||||
|
|
||||||
- `--title`, `--description`
|
|
||||||
- `--project PRJ-...`
|
|
||||||
- `--priority low|medium|high|urgent`
|
|
||||||
- `--assignees ana,luis`
|
|
||||||
- `--estimate 120m`
|
|
||||||
- `--due YYYY-MM-DD`
|
|
||||||
- `--tags`, `--systems`
|
|
||||||
- `--objective`
|
|
||||||
- `--acceptance` separado por comas
|
|
||||||
- `--notes`
|
|
||||||
- `--body-file ARCHIVO|-`
|
|
||||||
- `--from-json ARCHIVO|-`
|
|
||||||
|
|
||||||
### Iniciar y detener tiempo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task start TSK-... --note "Diagnóstico inicial"
|
|
||||||
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task stop TSK-... --note "Prueba completada"
|
|
||||||
```
|
|
||||||
|
|
||||||
Un administrador sólo puede mantener un temporizador activo. Los eventos se ordenan por fecha y tipo para producir un total reproducible.
|
|
||||||
|
|
||||||
### Ajustar tiempo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task adjust TSK-... --minutes 20 --note "Trabajo previo no registrado"
|
|
||||||
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task adjust TSK-... --minutes -10 --note "Corregir doble conteo"
|
|
||||||
```
|
|
||||||
|
|
||||||
El total efectivo nunca puede quedar negativo.
|
|
||||||
|
|
||||||
### Cambiar estado
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task status TSK-... --status blocked --note "Esperando refacción"
|
|
||||||
```
|
|
||||||
|
|
||||||
Estados admitidos aquí: `backlog`, `todo`, `in_progress`, `blocked`, `cancelled`. Para finalizar use `task done`.
|
|
||||||
|
|
||||||
### Completar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
task done TSK-... --note "Criterios de aceptación verificados"
|
|
||||||
```
|
|
||||||
|
|
||||||
Si el administrador actual mantiene el temporizador, la CLI lo detiene. Rechaza completar si otra persona sigue registrando tiempo en la misma tarea.
|
|
||||||
|
|
||||||
### Listar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json task list
|
|
||||||
clusterlog --root . --json task list --status blocked
|
|
||||||
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine
|
|
||||||
clusterlog --root . --json task list --project PRJ-...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Proyectos
|
|
||||||
|
|
||||||
### Crear
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat examples/project.json | clusterlog \
|
|
||||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
project create --from-json -
|
|
||||||
```
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
|
|
||||||
- `--title`, `--description`, `--objective`
|
|
||||||
- `--owners`
|
|
||||||
- `--target-date YYYY-MM-DD`
|
|
||||||
- `--status planned|active|blocked|done|cancelled|archived`
|
|
||||||
- `--tags`, `--systems`
|
|
||||||
- `--scope`, `--out-of-scope`
|
|
||||||
- `--milestones`, `--success`
|
|
||||||
- `--body-file ARCHIVO|-`
|
|
||||||
- `--from-json ARCHIVO|-`
|
|
||||||
|
|
||||||
### Listar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json project list
|
|
||||||
clusterlog --root . --json project list --status active
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Documentación y manuales
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat examples/manual.json | clusterlog \
|
|
||||||
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
|
|
||||||
content create --from-json -
|
|
||||||
```
|
|
||||||
|
|
||||||
También acepta:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub content create \
|
|
||||||
--section manuales \
|
|
||||||
--title "Reemplazar nodo de cómputo" \
|
|
||||||
--description "Procedimiento seguro y validaciones" \
|
|
||||||
--tags runbook,hardware \
|
|
||||||
--systems compute \
|
|
||||||
--body-file reemplazo.md \
|
|
||||||
--weight 20
|
|
||||||
```
|
|
||||||
|
|
||||||
`section` sólo puede ser `documentacion` o `manuales`.
|
|
||||||
|
|
||||||
## 11. Proyección
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . sync
|
|
||||||
clusterlog --root . sync --check
|
|
||||||
```
|
|
||||||
|
|
||||||
`sync` lee Markdown, administradores y eventos; genera `data/generated/site_state.json`. `--check` no escribe y falla cuando el archivo derivado no coincide con la fuente.
|
|
||||||
|
|
||||||
Los agentes no deben editar `data/generated/site_state.json` a mano.
|
|
||||||
|
|
||||||
## 12. Validación
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . --json validate
|
|
||||||
clusterlog --root . --json validate --skip-zola
|
|
||||||
clusterlog --root . --json validate --require-zola
|
|
||||||
```
|
|
||||||
|
|
||||||
Se comprueban, entre otros:
|
|
||||||
|
|
||||||
- registro de administradores y `allowed_signers`;
|
|
||||||
- IDs, autores, revisores, owners y assignees;
|
|
||||||
- estados, fechas y referencias de proyectos;
|
|
||||||
- eventos huérfanos o inconsistentes;
|
|
||||||
- temporizadores concurrentes y totales negativos;
|
|
||||||
- secretos probables;
|
|
||||||
- vigencia de la proyección;
|
|
||||||
- plantillas y enlaces con `zola check`, cuando está disponible.
|
|
||||||
|
|
||||||
## 13. Firmas
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --root . verify commit HEAD
|
|
||||||
clusterlog --root . verify commit abc1234
|
|
||||||
```
|
|
||||||
|
|
||||||
La verificación usa:
|
|
||||||
|
|
||||||
```text
|
|
||||||
config/allowed_signers
|
|
||||||
namespace: git
|
|
||||||
```
|
|
||||||
|
|
||||||
En CI conviene verificar todos los commits nuevos, no sólo `HEAD`.
|
|
||||||
|
|
||||||
## 14. Esquemas para agentes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clusterlog --json schema
|
|
||||||
clusterlog --json schema memory
|
|
||||||
clusterlog --json schema task
|
|
||||||
clusterlog --json schema project
|
|
||||||
clusterlog --json schema content
|
|
||||||
```
|
|
||||||
|
|
||||||
Los esquemas siguen JSON Schema 2020-12 y rechazan propiedades adicionales. Esta restricción permite detectar errores ortográficos en nombres de campos.
|
|
||||||
|
|
||||||
## 15. Patrón recomendado para agentes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCHEMA="$(clusterlog --json schema memory)"
|
|
||||||
# Construir input.json con base en data del sobre.
|
|
||||||
|
|
||||||
clusterlog --root . --json --dry-run --ssh-key "$KEY" \
|
|
||||||
memory create --from-json input.json
|
|
||||||
|
|
||||||
RESULT="$(clusterlog --root . --json --commit --ssh-key "$KEY" \
|
|
||||||
memory create --from-json input.json)"
|
|
||||||
|
|
||||||
printf '%s\n' "$RESULT"
|
|
||||||
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.
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
# Hoja de proyecto: Bitácora Operativa del Clúster
|
|
||||||
|
|
||||||
**Versión:** 0.1.0
|
|
||||||
**Fecha base:** 12 de agosto de 2026
|
|
||||||
**Nombre de trabajo:** Bitácora Operativa del Clúster
|
|
||||||
**CLI:** `clusterlog`
|
|
||||||
**Tipo de producto:** sitio interno estático + CLI de escritura + repositorio Git auditable
|
|
||||||
|
|
||||||
## 1. Resumen ejecutivo
|
|
||||||
|
|
||||||
El equipo de soporte necesita un lugar único para comprender **cómo está construido el clúster, cómo se opera, qué cambió, qué falta por hacer y qué debe revisar el resto del equipo**. La propuesta convierte un repositorio Git en la fuente de verdad y publica una proyección navegable mediante Zola, con la apariencia del tema `zola.386`.
|
|
||||||
|
|
||||||
La escritura no ocurre en el navegador. Administradores y agentes de programación usan una CLI no interactiva que:
|
|
||||||
|
|
||||||
- identifica al actor por la huella de su llave pública SSH;
|
|
||||||
- valida el contenido contra contratos predecibles;
|
|
||||||
- crea Markdown y eventos JSON pequeños;
|
|
||||||
- actualiza la proyección que consume Zola;
|
|
||||||
- y, cuando se solicita, deja todo en un commit Git firmado por SSH.
|
|
||||||
|
|
||||||
La página **Revisión** será la agenda inicial de las reuniones semanales. Muestra las memorias aún no revisadas por todos los administradores requeridos y deja de mostrarlas en cuanto la versión actual obtiene todas las confirmaciones.
|
|
||||||
|
|
||||||
## 2. Problema que resuelve
|
|
||||||
|
|
||||||
Hoy, el conocimiento operativo suele quedar fragmentado entre terminales, chats, tickets, wikis, memoria personal y commits técnicos. Esto produce cinco fallas:
|
|
||||||
|
|
||||||
1. No hay una narrativa cronológica de las intervenciones.
|
|
||||||
2. Los manuales y la arquitectura se desactualizan sin que exista una señal clara.
|
|
||||||
3. El equipo no sabe qué cambios ya fueron comprendidos por todos.
|
|
||||||
4. Las tareas y proyectos pierden contexto técnico y tiempo real invertido.
|
|
||||||
5. Los agentes de IA pueden ayudar a documentar, pero no disponen de un contrato seguro, determinista y atribuible.
|
|
||||||
|
|
||||||
## 3. Visión del producto
|
|
||||||
|
|
||||||
> Después de cada sesión de administración, el equipo debe poder reconstruir qué se intentó, qué se cambió, cómo se validó, cómo se revierte, quién lo ejecutó y quién ya lo revisó, sin depender de la memoria individual.
|
|
||||||
|
|
||||||
## 4. Objetivos
|
|
||||||
|
|
||||||
### Objetivos del MVP
|
|
||||||
|
|
||||||
- Centralizar documentación técnica, runbooks, memorias, proyectos y tareas.
|
|
||||||
- Hacer que registrar una memoria tome menos fricción que escribir una nota libre en otro sistema.
|
|
||||||
- Proporcionar a Claude Code y agentes similares un contrato JSON estable y no interactivo.
|
|
||||||
- Atribuir las escrituras a una identidad registrada por llave SSH.
|
|
||||||
- Hacer auditable la aprobación mediante commits SSH firmados.
|
|
||||||
- Construir una cola semanal de revisión reproducible y sin estado oculto en el navegador.
|
|
||||||
- Medir tiempo real de tareas sin introducir una base de datos.
|
|
||||||
- Mantener la solución portable: archivos legibles, Git y un generador estático.
|
|
||||||
|
|
||||||
### Objetivos posteriores al MVP
|
|
||||||
|
|
||||||
- Integración con CI corporativa, SSO, tickets y notificaciones.
|
|
||||||
- Importación de inventario o CMDB.
|
|
||||||
- Reportes de SLO, incidentes, cambios por sistema y deuda operativa.
|
|
||||||
- API remota controlada, sólo cuando el trabajo sin conexión y el modelo Git resulten insuficientes.
|
|
||||||
|
|
||||||
### Fuera de alcance del MVP
|
|
||||||
|
|
||||||
- Edición web multiusuario.
|
|
||||||
- Chat interno, comentarios en tiempo real o presencia.
|
|
||||||
- Almacenamiento de secretos, credenciales o volcados sensibles.
|
|
||||||
- Sustitución de monitoreo, ticketing, CMDB o gestión de incidentes.
|
|
||||||
- Identidad basada únicamente en el comentario textual de una llave.
|
|
||||||
- Firmar en nombre del usuario sin acceso autorizado a su llave privada o agente SSH.
|
|
||||||
|
|
||||||
## 5. Usuarios y responsabilidades
|
|
||||||
|
|
||||||
### Administrador del clúster
|
|
||||||
|
|
||||||
Crea memorias, documentación, manuales, tareas y proyectos; registra tiempo; revisa cambios ajenos; conserva su llave privada bajo su control.
|
|
||||||
|
|
||||||
### Owner de la bitácora
|
|
||||||
|
|
||||||
Da de alta administradores, mantiene la política de revisión, protege la rama principal y supervisa la validez de firmas.
|
|
||||||
|
|
||||||
### Agente de IA
|
|
||||||
|
|
||||||
Prepara contenido estructurado en nombre del administrador que inició la sesión. Usa la CLI y su salida JSON; no modifica archivos derivados a mano; no inventa resultados; no expone secretos.
|
|
||||||
|
|
||||||
### Lector técnico
|
|
||||||
|
|
||||||
Consulta el sitio, busca por texto, sistema o etiqueta y usa manuales y memorias como contexto operativo.
|
|
||||||
|
|
||||||
## 6. Arquitectura de información
|
|
||||||
|
|
||||||
| Sección | Propósito | Unidad principal | Cadencia esperada |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Documentación | Explicar estado y diseño vigentes | documento técnico | cuando cambia la arquitectura |
|
|
||||||
| Manuales | Describir procedimientos repetibles | runbook/manual | cuando cambia un procedimiento |
|
|
||||||
| Memorias | Narrar una intervención concreta | entrada cronológica | después de cada sesión o cambio |
|
|
||||||
| Proyectos | Coordinar un resultado de varias tareas | proyecto | semanal/mensual |
|
|
||||||
| Tareas | Registrar trabajo accionable y tiempo | tarea | diaria |
|
|
||||||
| Revisión | Alinear al equipo sobre cambios recientes | proyección de pendientes | semanal |
|
|
||||||
|
|
||||||
### Taxonomías comunes
|
|
||||||
|
|
||||||
- `tags`: tema transversal, por ejemplo `firmware`, `backup`, `seguridad`.
|
|
||||||
- `systems`: componente real afectado, por ejemplo `etcd`, `spine-01`, `ceph`.
|
|
||||||
- `categories`: sección funcional.
|
|
||||||
|
|
||||||
### Convención de identificadores
|
|
||||||
|
|
||||||
- Documentación: `DOC-...`
|
|
||||||
- Manuales: `RUN-...`
|
|
||||||
- Memorias: `MEM-...`
|
|
||||||
- Proyectos: `PRJ-...`
|
|
||||||
- Tareas: `TSK-...`
|
|
||||||
- Eventos: identificadores únicos propios, almacenados en archivos independientes.
|
|
||||||
|
|
||||||
## 7. Modelo de datos
|
|
||||||
|
|
||||||
### Memoria operativa
|
|
||||||
|
|
||||||
Campos mínimos o recomendados:
|
|
||||||
|
|
||||||
- ID, título, resumen y fecha.
|
|
||||||
- autor registrado y huella SSH empleada;
|
|
||||||
- agente que ayudó a redactar, cuando aplique;
|
|
||||||
- sistemas, etiquetas, ticket y nivel de riesgo;
|
|
||||||
- contexto, cambios, comandos redactados, validaciones y reversión;
|
|
||||||
- inicio, fin y duración;
|
|
||||||
- requisito y política de revisión;
|
|
||||||
- conjunto capturado de revisores;
|
|
||||||
- hash SHA-256 de la versión actual, calculado por la CLI.
|
|
||||||
|
|
||||||
### Evento de revisión
|
|
||||||
|
|
||||||
Cada confirmación vive en `data/review-events/<evento>.json` e incluye:
|
|
||||||
|
|
||||||
- memoria;
|
|
||||||
- administrador y huella;
|
|
||||||
- fecha;
|
|
||||||
- hash exacto del contenido revisado;
|
|
||||||
- nota opcional.
|
|
||||||
|
|
||||||
No existe un booleano mutable `reviewed=true`. La revisión se deriva de eventos válidos para el hash actual.
|
|
||||||
|
|
||||||
### Tarea
|
|
||||||
|
|
||||||
La definición Markdown contiene título, objetivo, prioridad, responsables, proyecto, estimación y fecha. Los cambios de ejecución viven como eventos independientes:
|
|
||||||
|
|
||||||
- `start`;
|
|
||||||
- `stop`;
|
|
||||||
- `adjust`;
|
|
||||||
- `status`.
|
|
||||||
|
|
||||||
El tiempo real y el estado efectivo se proyectan al sincronizar.
|
|
||||||
|
|
||||||
### Proyecto
|
|
||||||
|
|
||||||
Incluye objetivo medible, responsables, estado, fecha objetivo, alcance, fuera de alcance, hitos y criterios de éxito.
|
|
||||||
|
|
||||||
## 8. Semántica de revisión
|
|
||||||
|
|
||||||
La revisión debe responder: **“¿qué administradores confirmaron haber comprendido esta versión exacta?”**
|
|
||||||
|
|
||||||
Reglas:
|
|
||||||
|
|
||||||
1. Al crear una memoria se captura el conjunto de revisores aplicable en ese momento.
|
|
||||||
2. La política puede requerir a todos los activos o a todos excepto el autor.
|
|
||||||
3. Cada evento registra el hash SHA-256 actual de la memoria.
|
|
||||||
4. Sólo cuentan eventos cuyo hash coincide con la versión actual.
|
|
||||||
5. Una edición posterior cambia el hash y vuelve a abrir la memoria automáticamente.
|
|
||||||
6. Dar de alta a un administrador en el futuro no reabre memorias históricas, porque cada memoria conserva su conjunto capturado.
|
|
||||||
7. Una memoria sale de `/revision/` cuando no falta ningún revisor.
|
|
||||||
8. La firma del commit, no la mera presencia de la huella en JSON, aporta la prueba criptográfica de autoría.
|
|
||||||
|
|
||||||
## 9. Identidad y confianza
|
|
||||||
|
|
||||||
### Resolución de identidad
|
|
||||||
|
|
||||||
La CLI busca una llave local en este orden general:
|
|
||||||
|
|
||||||
1. `--ssh-key`;
|
|
||||||
2. `CLUSTERLOG_SSH_KEY`;
|
|
||||||
3. `git config user.signingkey` cuando es una ruta;
|
|
||||||
4. llaves públicas comunes dentro de `~/.ssh`.
|
|
||||||
|
|
||||||
Calcula la huella OpenSSH SHA-256 de la llave pública y la compara con `data/admins.json`.
|
|
||||||
|
|
||||||
### Límite importante
|
|
||||||
|
|
||||||
Conocer una llave pública sólo identifica un registro; no demuestra posesión de la privada. Por ello, el flujo de producción debe exigir:
|
|
||||||
|
|
||||||
- `--commit` en operaciones de escritura;
|
|
||||||
- commits SSH firmados;
|
|
||||||
- `config/allowed_signers` generado desde administradores activos;
|
|
||||||
- verificación en CI;
|
|
||||||
- protección de rama y revisión de cambios en archivos de identidad.
|
|
||||||
|
|
||||||
## 10. Requisitos funcionales
|
|
||||||
|
|
||||||
### RF-01. Navegación central
|
|
||||||
|
|
||||||
El sitio debe exponer las seis secciones principales y búsqueda global.
|
|
||||||
|
|
||||||
**Aceptación:** desde cualquier página se llega a cada sección en un paso.
|
|
||||||
|
|
||||||
### RF-02. Creación de memorias por CLI
|
|
||||||
|
|
||||||
La CLI debe aceptar flags humanos o JSON por archivo/`stdin`.
|
|
||||||
|
|
||||||
**Aceptación:** `memory create --from-json -` produce una página válida, un ID y una proyección actualizada.
|
|
||||||
|
|
||||||
### RF-03. Atribución
|
|
||||||
|
|
||||||
Toda escritura debe guardar el ID del administrador y la huella resuelta.
|
|
||||||
|
|
||||||
**Aceptación:** una llave no registrada falla con código de identidad y no escribe archivos.
|
|
||||||
|
|
||||||
### RF-04. Revisión por todos
|
|
||||||
|
|
||||||
La página de revisión debe mostrar faltantes y desaparecer la memoria al completar el conjunto requerido.
|
|
||||||
|
|
||||||
**Aceptación:** con dos revisores, la entrada sigue visible tras una confirmación y desaparece tras la segunda.
|
|
||||||
|
|
||||||
### RF-05. Invalidación por edición
|
|
||||||
|
|
||||||
Una revisión anterior no debe contar si cambia la memoria.
|
|
||||||
|
|
||||||
**Aceptación:** modificar el cuerpo y ejecutar `sync` vuelve a mostrar la entrada.
|
|
||||||
|
|
||||||
### RF-06. Tareas y tiempo
|
|
||||||
|
|
||||||
Debe ser posible crear, iniciar, detener, ajustar y completar una tarea.
|
|
||||||
|
|
||||||
**Aceptación:** el tiempo real es la suma reproducible de eventos y no puede ser negativo.
|
|
||||||
|
|
||||||
### RF-07. Proyectos
|
|
||||||
|
|
||||||
Debe ser posible crear y listar proyectos con objetivo y estado.
|
|
||||||
|
|
||||||
**Aceptación:** las tareas pueden enlazar un proyecto existente y la validación rechaza referencias inexistentes.
|
|
||||||
|
|
||||||
### RF-08. Operación por agentes
|
|
||||||
|
|
||||||
Debe existir salida JSON estable, esquemas JSON y `--dry-run`.
|
|
||||||
|
|
||||||
**Aceptación:** un agente puede descubrir el esquema, validar y escribir sin prompts ni parsing de texto humano.
|
|
||||||
|
|
||||||
### RF-09. Validación
|
|
||||||
|
|
||||||
La CLI debe revisar estructura, referencias, eventos, firmas autorizables, secretos probables, proyección y Zola.
|
|
||||||
|
|
||||||
**Aceptación:** `validate --require-zola` falla ante cualquier error bloqueante.
|
|
||||||
|
|
||||||
### RF-10. Auditoría Git
|
|
||||||
|
|
||||||
Una operación con `--commit` debe añadir sólo los archivos que creó o modificó y producir un commit SSH firmado.
|
|
||||||
|
|
||||||
**Aceptación:** `verify commit <sha>` confirma la firma contra `config/allowed_signers`.
|
|
||||||
|
|
||||||
## 11. Requisitos no funcionales
|
|
||||||
|
|
||||||
- **Portabilidad:** Markdown, JSON y Git deben seguir siendo legibles sin la CLI.
|
|
||||||
- **Determinismo:** la misma fuente genera la misma proyección, salvo la marca informativa `generated_at` controlada por la sincronización.
|
|
||||||
- **Concurrencia Git:** cada revisión y evento de tarea ocupa un archivo distinto para minimizar conflictos.
|
|
||||||
- **Seguridad:** no almacenar secretos; sitio no indexable y desplegado en red privada.
|
|
||||||
- **Accesibilidad:** navegación semántica, uso por teclado y contenido legible sin JavaScript salvo la búsqueda.
|
|
||||||
- **Mantenibilidad:** plantillas locales separadas del tema visual fijado.
|
|
||||||
- **Observabilidad:** errores con `kind`, detalles y código de salida estable.
|
|
||||||
- **Recuperación:** toda proyección puede eliminarse y regenerarse desde la fuente.
|
|
||||||
|
|
||||||
## 12. Diseño de la CLI
|
|
||||||
|
|
||||||
Forma general:
|
|
||||||
|
|
||||||
```text
|
|
||||||
clusterlog [--root DIR] [--json] [--ssh-key PATH] [--commit] [--dry-run] COMANDO
|
|
||||||
```
|
|
||||||
|
|
||||||
Principios:
|
|
||||||
|
|
||||||
- flags globales antes del comando;
|
|
||||||
- sin prompts interactivos;
|
|
||||||
- operaciones de creación atómicas;
|
|
||||||
- `stdin` con `--from-json -` o `--body-file -`;
|
|
||||||
- respuesta JSON envuelta en `{ok, command, data, warnings}`;
|
|
||||||
- errores JSON con `kind`, `message`, `details` y código de proceso;
|
|
||||||
- no sobrescribir eventos existentes;
|
|
||||||
- comandos idempotentes cuando existe un estado equivalente, por ejemplo revisar dos veces el mismo hash.
|
|
||||||
|
|
||||||
La referencia exhaustiva está en `CLI.md`.
|
|
||||||
|
|
||||||
## 13. Arquitectura técnica
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌───────────────────────────────┐
|
|
||||||
│ Humano / Claude Code / agente │
|
|
||||||
└───────────────┬───────────────┘
|
|
||||||
│ JSON o flags
|
|
||||||
v
|
|
||||||
┌───────────────────────────────┐
|
|
||||||
│ clusterlog (Go) │
|
|
||||||
│ identidad · validación · Git │
|
|
||||||
└───────┬──────────────┬────────┘
|
|
||||||
│ │
|
|
||||||
│ Markdown │ eventos JSON inmutables
|
|
||||||
v v
|
|
||||||
┌───────────────────────────────────────────────┐
|
|
||||||
│ Repositorio Git │
|
|
||||||
│ content/ · data/ · config/allowed_signers │
|
|
||||||
└───────────────────┬───────────────────────────┘
|
|
||||||
│ sync
|
|
||||||
v
|
|
||||||
┌───────────────────────────────────────────────┐
|
|
||||||
│ data/generated/site_state.json │
|
|
||||||
│ cola · métricas · tareas · proyectos · memoria│
|
|
||||||
└───────────────────┬───────────────────────────┘
|
|
||||||
│ load_data
|
|
||||||
v
|
|
||||||
┌───────────────────────────────────────────────┐
|
|
||||||
│ Zola + recursos visuales de zola.386 │
|
|
||||||
└───────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Decisión sobre estado compartido
|
|
||||||
|
|
||||||
No se usa `localStorage`, una cookie ni un formulario estático para las revisiones. Esos mecanismos no representan un consenso compartido ni son auditables. El estado se conserva en Git y Zola sólo lo presenta.
|
|
||||||
|
|
||||||
### Decisión sobre el tema
|
|
||||||
|
|
||||||
Se fijan Sass y archivos estáticos de `zola.386` a una revisión concreta. Las plantillas son locales, porque el tema original precede a cambios mayores de Zola/Tera y el proyecto necesita estructuras específicas para revisión, tareas y proyectos.
|
|
||||||
|
|
||||||
## 14. Alcance del MVP entregado
|
|
||||||
|
|
||||||
### Incluido
|
|
||||||
|
|
||||||
- estructura Zola y navegación;
|
|
||||||
- tema visual preparado mediante script;
|
|
||||||
- CLI Go funcional;
|
|
||||||
- contratos JSON y ejemplos;
|
|
||||||
- registro de administradores;
|
|
||||||
- memoria/revisión por hash;
|
|
||||||
- tareas y eventos de tiempo;
|
|
||||||
- proyectos;
|
|
||||||
- proyección para el sitio;
|
|
||||||
- validación y escaneo de secretos probables;
|
|
||||||
- commits y verificación SSH;
|
|
||||||
- pruebas Go;
|
|
||||||
- CI para Zola 0.23.3;
|
|
||||||
- documentación de seguridad y operación.
|
|
||||||
|
|
||||||
### Pendiente para producción
|
|
||||||
|
|
||||||
- reemplazar dominio de ejemplo;
|
|
||||||
- decidir hosting interno y autenticación;
|
|
||||||
- configurar protección de rama y verificación obligatoria de firmas;
|
|
||||||
- poblar administradores reales;
|
|
||||||
- revisar taxonomía de sistemas;
|
|
||||||
- probar la canalización en el repositorio corporativo;
|
|
||||||
- definir retención y clasificación de información;
|
|
||||||
- integrar respaldos del repositorio.
|
|
||||||
|
|
||||||
## 15. Fases recomendadas
|
|
||||||
|
|
||||||
### Fase 0 — Alineación y seguridad
|
|
||||||
|
|
||||||
- Aprobar nombres, secciones y política de revisión.
|
|
||||||
- Definir qué información está prohibida.
|
|
||||||
- Seleccionar repositorio y hosting interno.
|
|
||||||
- Registrar owners iniciales.
|
|
||||||
|
|
||||||
**Salida:** decisiones aprobadas y threat model aceptado.
|
|
||||||
|
|
||||||
### Fase 1 — Piloto técnico
|
|
||||||
|
|
||||||
- Desplegar el prototipo.
|
|
||||||
- Incorporar de dos a cuatro administradores.
|
|
||||||
- Importar cinco manuales y diez memorias recientes.
|
|
||||||
- Ejecutar dos reuniones semanales usando `/revision/`.
|
|
||||||
|
|
||||||
**Salida:** retroalimentación sobre fricción, campos y cola.
|
|
||||||
|
|
||||||
### Fase 2 — Adopción operativa
|
|
||||||
|
|
||||||
- Añadir plantilla a las instrucciones de Claude Code.
|
|
||||||
- Exigir `validate` y firmas en pull requests.
|
|
||||||
- Migrar proyectos y tareas activas.
|
|
||||||
- Establecer responsables de calidad documental.
|
|
||||||
|
|
||||||
**Salida:** bitácora como flujo normal, no como actividad extraordinaria.
|
|
||||||
|
|
||||||
### Fase 3 — Integraciones
|
|
||||||
|
|
||||||
- Enlaces automáticos a tickets.
|
|
||||||
- Notificaciones de revisión.
|
|
||||||
- Métricas y reportes de mantenimiento.
|
|
||||||
- Evaluar una API sólo con casos concretos que Git no resuelva.
|
|
||||||
|
|
||||||
## 16. Riesgos y mitigaciones
|
|
||||||
|
|
||||||
| Riesgo | Consecuencia | Mitigación |
|
|
||||||
|---|---|---|
|
|
||||||
| El agente incluye un secreto | exposición en Git y sitio | contrato explícito, escáner heurístico, revisión humana y herramientas corporativas de secret scanning |
|
|
||||||
| Se suplanta una huella en un JSON | falsa atribución aparente | exigir commit SSH firmado y verificarlo en CI |
|
|
||||||
| Dos personas editan el mismo estado | conflictos de merge | un archivo por evento y Markdown por entidad |
|
|
||||||
| Una memoria cambia después de revisarse | consenso obsoleto | hash de contenido en cada revisión |
|
|
||||||
| Un nuevo admin reabre todo el histórico | cola inmanejable | captura de revisores por memoria |
|
|
||||||
| El tema deja de compilar | bloqueo de publicación | revisión fijada, plantillas locales y build en CI |
|
|
||||||
| El sitio se expone a Internet | fuga de conocimiento operativo | VPN/SSO, `robots.txt`, `noindex`, mínimo privilegio |
|
|
||||||
| La documentación se vuelve burocrática | baja adopción | JSON preparado por agente, campos mínimos y plantillas breves |
|
|
||||||
| Git crece con muchos eventos | mantenimiento | eventos pequeños, política de archivo posterior y métricas reales antes de rediseñar |
|
|
||||||
|
|
||||||
## 17. Métricas de éxito del piloto
|
|
||||||
|
|
||||||
- Porcentaje de cambios relevantes con memoria dentro de 24 horas.
|
|
||||||
- Tiempo mediano para registrar una memoria.
|
|
||||||
- Porcentaje de memorias revisadas antes de la siguiente reunión.
|
|
||||||
- Antigüedad máxima de un pendiente de revisión.
|
|
||||||
- Número de manuales actualizados a partir de hallazgos en memorias.
|
|
||||||
- Diferencia entre tiempo estimado y real por categoría de tareas.
|
|
||||||
- Errores de validación o secretos bloqueados antes de llegar a la rama principal.
|
|
||||||
|
|
||||||
Las métricas deben usarse para mejorar el sistema, no para evaluar individualmente el desempeño sin contexto.
|
|
||||||
|
|
||||||
## 18. Criterios de aceptación del MVP
|
|
||||||
|
|
||||||
El MVP se considera aceptado cuando:
|
|
||||||
|
|
||||||
1. La CLI compila y todas las pruebas automatizadas pasan.
|
|
||||||
2. Zola construye el sitio con el tema preparado y las plantillas locales.
|
|
||||||
3. Un owner puede registrar a un segundo administrador.
|
|
||||||
4. Una memoria creada por JSON aparece en Memorias y Revisión.
|
|
||||||
5. Cada administrador puede revisarla desde su propia llave.
|
|
||||||
6. La memoria desaparece al completar el conjunto de revisores.
|
|
||||||
7. Editar la memoria vuelve a abrir su revisión.
|
|
||||||
8. Una tarea registra tiempo y puede completarse sin producir minutos negativos.
|
|
||||||
9. Una tarea puede enlazarse a un proyecto existente.
|
|
||||||
10. La validación detecta referencias inválidas, temporizadores inconsistentes y secretos probables.
|
|
||||||
11. Los commits creados con `--commit` se verifican contra `allowed_signers`.
|
|
||||||
12. La rama principal exige CI y firmas según la política del repositorio.
|
|
||||||
13. El sitio está accesible sólo en el entorno interno acordado.
|
|
||||||
|
|
||||||
## 19. Definición de terminado para cada cambio
|
|
||||||
|
|
||||||
Un cambio está terminado cuando:
|
|
||||||
|
|
||||||
- tiene pruebas o una justificación explícita de por qué no aplican;
|
|
||||||
- no introduce secretos ni datos personales innecesarios;
|
|
||||||
- actualiza esquema, documentación y ejemplo cuando cambia un contrato;
|
|
||||||
- ejecuta `go test ./...`, `go vet ./...`, `clusterlog sync --check` y `clusterlog validate`;
|
|
||||||
- construye con Zola en CI;
|
|
||||||
- conserva compatibilidad de salida JSON o incrementa versión con migración;
|
|
||||||
- queda en un commit firmado y revisable.
|
|
||||||
|
|
||||||
## 20. Backlog inmediato priorizado
|
|
||||||
|
|
||||||
### P0 — antes del piloto
|
|
||||||
|
|
||||||
- Configurar dominio interno, hosting y acceso.
|
|
||||||
- Ejecutar CI en el repositorio real.
|
|
||||||
- Registrar owners y política de firmas.
|
|
||||||
- Revisar patrones del escáner de secretos con ejemplos del entorno.
|
|
||||||
- Crear los primeros manuales críticos: acceso, respaldo, restauración y escalamiento.
|
|
||||||
|
|
||||||
### P1 — durante el piloto
|
|
||||||
|
|
||||||
- Añadir edición controlada de tareas y proyectos mediante eventos o comandos específicos.
|
|
||||||
- Añadir desactivación/rotación de llaves de administradores.
|
|
||||||
- Añadir verificación de firma para un rango de commits en CI.
|
|
||||||
- Generar una vista “mis pendientes”.
|
|
||||||
- Incorporar relaciones entre memorias y documentación que debe actualizarse.
|
|
||||||
|
|
||||||
### P2 — después de validar adopción
|
|
||||||
|
|
||||||
- Notificaciones de revisión pendientes.
|
|
||||||
- Importación de tickets.
|
|
||||||
- Reportes de actividad por sistema y riesgo.
|
|
||||||
- Archivado por periodo y política de retención.
|
|
||||||
- API o servicio de escritura sólo si los flujos Git resultan insuficientes.
|
|
||||||
|
|
||||||
## 21. Preguntas de gobierno que el equipo debe cerrar
|
|
||||||
|
|
||||||
- ¿El autor debe revisar su propia memoria o se excluye por defecto?
|
|
||||||
- ¿Qué riesgo exige revisión síncrona antes del cambio y cuál permite revisión posterior?
|
|
||||||
- ¿Cuánto tiempo puede permanecer una memoria pendiente?
|
|
||||||
- ¿Quién puede registrar, desactivar o rotar llaves?
|
|
||||||
- ¿Qué datos de infraestructura están permitidos en el sitio?
|
|
||||||
- ¿Qué sistema conserva el ticket canónico y cuál sólo enlaza?
|
|
||||||
- ¿Cuándo una memoria obliga a actualizar un manual o documento de arquitectura?
|
|
||||||
- ¿Qué estados de proyecto y tarea necesita realmente el equipo?
|
|
||||||
|
|
||||||
Estas decisiones son configuración y proceso; no requieren convertir el MVP en una aplicación con base de datos.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Proyecto Bitácora Operativa del Clúster
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
SHELL := /usr/bin/env bash
|
SHELL := /usr/bin/env bash
|
||||||
GO ?= go
|
|
||||||
ZOLA ?= zola
|
ZOLA ?= zola
|
||||||
BINARY ?= bin/clusterlog
|
BINARY ?= bin/clusterlog
|
||||||
|
|
||||||
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean
|
.PHONY: all build theme templates sync sync-check validate validate-local site serve smoke ci clean runme-install runme-list runme-check runme-ci
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
|
# "build" ya no compila (esta instancia no tiene código Go): descarga el
|
||||||
|
# binario fijado en .clusterlog-version desde fenandosr/clusterlog,
|
||||||
|
# verificando su checksum. Se mantiene el nombre "build" para no romper
|
||||||
|
# RUNBOOK.md ni el hábito de make build/make sync-check de siempre.
|
||||||
build:
|
build:
|
||||||
mkdir -p $(dir $(BINARY))
|
./scripts/fetch-engine.sh $(BINARY)
|
||||||
$(GO) build -trimpath -o $(BINARY) ./cmd/clusterlog
|
|
||||||
|
|
||||||
test:
|
|
||||||
$(GO) test ./...
|
|
||||||
|
|
||||||
vet:
|
|
||||||
$(GO) vet ./...
|
|
||||||
|
|
||||||
e2e: build
|
|
||||||
./scripts/e2e-local.sh
|
|
||||||
|
|
||||||
theme:
|
theme:
|
||||||
./scripts/bootstrap-theme.sh
|
./scripts/bootstrap-theme.sh
|
||||||
|
|
||||||
|
# Las plantillas viajan embebidas en el binario del motor desde v0.2.0 (ver
|
||||||
|
# ADR-0006 del motor): no hay red aparte para esto, sólo se extraen.
|
||||||
|
templates: build
|
||||||
|
./$(BINARY) --root . templates extract
|
||||||
|
|
||||||
sync: build
|
sync: build
|
||||||
./$(BINARY) --root . sync
|
./$(BINARY) --root . sync
|
||||||
|
|
||||||
@@ -35,17 +33,29 @@ validate: build
|
|||||||
validate-local: build
|
validate-local: build
|
||||||
./$(BINARY) --root . validate --skip-zola
|
./$(BINARY) --root . validate --skip-zola
|
||||||
|
|
||||||
site: theme sync validate
|
site: theme templates sync validate
|
||||||
$(ZOLA) build
|
$(ZOLA) build
|
||||||
|
|
||||||
serve: theme sync
|
serve: theme templates sync
|
||||||
$(ZOLA) serve --interface 127.0.0.1
|
$(ZOLA) serve --interface 127.0.0.1
|
||||||
|
|
||||||
smoke: build
|
smoke: build
|
||||||
./scripts/smoke.sh
|
./scripts/smoke.sh
|
||||||
|
|
||||||
ci: theme test vet e2e build sync sync-check validate
|
ci: theme templates build sync sync-check validate
|
||||||
$(ZOLA) build
|
$(ZOLA) build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin public themes/zola.386
|
rm -rf bin public themes/zola.386 templates
|
||||||
|
|
||||||
|
runme-install:
|
||||||
|
./scripts/runme-install.sh
|
||||||
|
|
||||||
|
runme-list:
|
||||||
|
runme ls --filename RUNBOOK.md
|
||||||
|
|
||||||
|
runme-check: build
|
||||||
|
./scripts/runme-check.sh
|
||||||
|
|
||||||
|
runme-ci: build
|
||||||
|
./scripts/runme-ci.sh
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
# Bitácora Operativa del Clúster
|
# Bitácora Operativa del Clúster (MCPS)
|
||||||
|
|
||||||
Sitio interno y CLI auditable para conservar en un solo lugar la documentación, los manuales, las memorias de cambio, los proyectos, las tareas y la agenda de revisión semanal del equipo que administra un clúster.
|
Instancia de [`clusterlog`](https://github.com/fenandosr/clusterlog): sitio
|
||||||
|
interno y CLI auditable para conservar en un solo lugar la documentación,
|
||||||
|
los manuales, las memorias de cambio, los proyectos, las tareas y la agenda
|
||||||
|
de revisión semanal del equipo que administra el clúster MCPS.
|
||||||
|
|
||||||
|
Este repositorio contiene únicamente **datos de este deployment**
|
||||||
|
(`content/`, `data/`, `config/`, `zola.toml`, `.clusterlog-version`). El
|
||||||
|
código de la CLI, las plantillas y el mecanismo de tema viven en el motor:
|
||||||
|
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog) — ver
|
||||||
|
`docs/adr/0005-motor-instancia.md` de ese repositorio para el porqué de la
|
||||||
|
separación.
|
||||||
|
|
||||||
La solución separa responsabilidades:
|
La solución separa responsabilidades:
|
||||||
|
|
||||||
@@ -13,7 +23,7 @@ administrador o agente IA
|
|||||||
| ---- escribe Markdown/eventos JSON
|
| ---- escribe Markdown/eventos JSON
|
||||||
| ---- opcionalmente crea commit SSH firmado
|
| ---- opcionalmente crea commit SSH firmado
|
||||||
v
|
v
|
||||||
repositorio Git ---- fuente de verdad y auditoría
|
repositorio Git ---- fuente de verdad y auditoría (esta instancia)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
clusterlog sync ---- genera data/generated/site_state.json
|
clusterlog sync ---- genera data/generated/site_state.json
|
||||||
@@ -22,7 +32,7 @@ administrador o agente IA
|
|||||||
Zola ---- lectura, navegación, taxonomías y búsqueda
|
Zola ---- lectura, navegación, taxonomías y búsqueda
|
||||||
```
|
```
|
||||||
|
|
||||||
## Qué incluye el MVP
|
## Qué incluye
|
||||||
|
|
||||||
- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**.
|
- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**.
|
||||||
- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión.
|
- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión.
|
||||||
@@ -30,69 +40,45 @@ administrador o agente IA
|
|||||||
- Invalidación automática de revisiones cuando cambia el contenido de una memoria.
|
- Invalidación automática de revisiones cuando cambia el contenido de una memoria.
|
||||||
- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`.
|
- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`.
|
||||||
- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado.
|
- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado.
|
||||||
- CLI no interactiva, salida JSON, entrada JSON por archivo o `stdin`, modo `--dry-run` y códigos de salida estables.
|
|
||||||
- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`.
|
- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`.
|
||||||
- Detección heurística de secretos antes de escribir contenido.
|
|
||||||
- Estado derivado y reproducible para que Zola siga siendo un sitio estático.
|
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- Go 1.23 o posterior.
|
|
||||||
- Git con soporte de firmas SSH.
|
- Git con soporte de firmas SSH.
|
||||||
- OpenSSH/`ssh-keygen` para firmar y verificar commits.
|
- OpenSSH/`ssh-keygen` para firmar y verificar commits.
|
||||||
- Zola 0.23.3 para construir el sitio.
|
- Zola 0.23.3 para construir el sitio.
|
||||||
- Acceso al repositorio del tema durante `make theme`, o una copia local indicada con `ZOLA386_SOURCE`.
|
- **No requiere Go ni un checkout del motor**: `make build`/`make templates` descargan el binario y las plantillas fijadas en [`.clusterlog-version`](.clusterlog-version) desde `fenandosr/clusterlog`, verificando su checksum.
|
||||||
|
|
||||||
## Inicio rápido
|
## Inicio rápido
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make theme
|
make theme # tema zola.386 (repositorio externo, pinneado)
|
||||||
make build
|
make templates # plantillas del motor, versión fijada
|
||||||
|
make build # binario clusterlog, versión fijada
|
||||||
./bin/clusterlog --root . sync
|
./bin/clusterlog --root . sync
|
||||||
./bin/clusterlog --root . validate --skip-zola
|
./bin/clusterlog --root . validate --skip-zola
|
||||||
zola serve
|
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 publicar un cambio a `base_url`/`zola.toml`, manténgalo detrás de VPN, red privada o autenticación corporativa.
|
||||||
|
|
||||||
## Registrar al primer administrador
|
## Registrar un administrador nuevo
|
||||||
|
|
||||||
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.
|
Sólo un `owner` activo puede hacerlo (ver `content/manuales/primer-administrador.md` para el procedimiento completo, incluido el alta del segundo administrador).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./bin/clusterlog \
|
./bin/clusterlog \
|
||||||
--root . \
|
--root . \
|
||||||
--ssh-key ~/.ssh/id_ed25519.pub \
|
--ssh-key ~/.ssh/id_ed25519.pub \
|
||||||
|
--commit \
|
||||||
admin add \
|
admin add \
|
||||||
--id ana \
|
--id ana \
|
||||||
--name "Ana Torres" \
|
--name "Ana Torres" \
|
||||||
--email ana@example.org \
|
--email ana@example.org \
|
||||||
--key ~/.ssh/id_ed25519.pub \
|
--key ~/.ssh/id_ed25519.pub \
|
||||||
--roles owner,admin
|
--roles admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure Git para firmar el primer commit completo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
git config user.name "Ana Torres"
|
|
||||||
git config user.email ana@example.org
|
|
||||||
git config gpg.format ssh
|
|
||||||
git config user.signingkey ~/.ssh/id_ed25519
|
|
||||||
git config commit.gpgsign true
|
|
||||||
git add .
|
|
||||||
git commit -S -m "bootstrap: iniciar bitácora operativa"
|
|
||||||
```
|
|
||||||
|
|
||||||
A partir de ahí:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit identity
|
|
||||||
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit memory create --from-json examples/memory.json
|
|
||||||
```
|
|
||||||
|
|
||||||
`identity` no modifica el repositorio; `--commit` sólo tiene efecto en comandos que escriben archivos.
|
|
||||||
|
|
||||||
## Flujo para Claude Code u otro agente
|
## Flujo para Claude Code u otro agente
|
||||||
|
|
||||||
1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles.
|
1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles.
|
||||||
@@ -126,6 +112,8 @@ Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [`
|
|||||||
|
|
||||||
## Flujo de revisión semanal
|
## Flujo de revisión semanal
|
||||||
|
|
||||||
|
Versión ejecutable (Runme) en [`runbooks/weekly-review.md`](runbooks/weekly-review.md); agenda y criterio en [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver sólo las memorias que le faltan al administrador actual
|
# Ver sólo las memorias que le faltan al administrador actual
|
||||||
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
|
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
|
||||||
@@ -143,41 +131,49 @@ La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión,
|
|||||||
## Comandos de calidad
|
## Comandos de calidad
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # pruebas unitarias e integración de la CLI
|
make sync-check # comprueba que la proyección está actualizada
|
||||||
make vet # análisis estático de Go
|
make validate-local # valida sin requerir Zola
|
||||||
make e2e # flujo completo en una copia temporal
|
make site # tema + plantillas + estado + validación completa + sitio
|
||||||
make smoke # contratos, sincronización y validación local
|
make smoke # contratos, sincronización y validación local
|
||||||
make validate-local # sin requerir Zola
|
|
||||||
make site # tema + estado + validación completa + sitio
|
|
||||||
make ci # equivalente local de la canalización
|
make ci # equivalente local de la canalización
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documentación ejecutable (Runme)
|
||||||
|
|
||||||
|
Los procedimientos frecuentes también existen como notebooks ejecutables,
|
||||||
|
sin reemplazar ningún comando canónico: [`RUNBOOK.md`](RUNBOOK.md) es el
|
||||||
|
punto de entrada. Requiere Runme `v3.17.4` (`make runme-install`) sólo si se
|
||||||
|
quiere abrir como notebook o correr por CLI; cada celda es, además, un
|
||||||
|
bloque de shell normal que sigue funcionando copiado a mano. Modelo de
|
||||||
|
identidad, limitaciones y la decisión completa viven en el motor:
|
||||||
|
[`docs/RUNME.md`](https://github.com/fenandosr/clusterlog/blob/main/docs/RUNME.md)
|
||||||
|
y
|
||||||
|
[ADR-0004](https://github.com/fenandosr/clusterlog/blob/main/docs/adr/0004-runme-sidecar.md).
|
||||||
|
|
||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
```text
|
```text
|
||||||
cmd/clusterlog/ punto de entrada de la CLI
|
content/ Markdown fuente por sección (memorias, manuales, ...)
|
||||||
internal/clusterlog/ modelo, comandos, validación y proyección
|
|
||||||
content/ Markdown fuente por sección
|
|
||||||
data/admins.json registro de administradores y política
|
data/admins.json registro de administradores y política
|
||||||
data/review-events/ un archivo por revisión
|
data/review-events/ un archivo por revisión
|
||||||
data/task-events/ un archivo por evento de tiempo/estado
|
data/task-events/ un archivo por evento de tiempo/estado
|
||||||
data/generated/ proyección regenerable para Zola
|
data/generated/ proyección regenerable para Zola
|
||||||
templates/ plantillas locales compatibles con Zola actual
|
config/allowed_signers firmantes SSH autorizados (generado junto a admins.json)
|
||||||
static/ ajustes visuales y búsqueda
|
templates/, themes/ descargados por make templates/make theme — no versionados
|
||||||
scripts/ preparación del tema y smoke tests
|
static/ ajustes visuales y búsqueda propios de esta instancia
|
||||||
docs/ decisiones arquitectónicas y operación
|
scripts/ fetch del motor, tema, smoke test, verificación de firmas
|
||||||
|
runbooks/, RUNBOOK.md documentación ejecutable de esta instancia
|
||||||
|
docs/ guion de la reunión semanal
|
||||||
|
.clusterlog-version versión fijada del motor que usa esta instancia
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentación clave
|
## Documentación clave
|
||||||
|
|
||||||
- [`HOJA_DE_PROYECTO.md`](HOJA_DE_PROYECTO.md): alcance, requisitos, fases y criterios de aceptación.
|
|
||||||
- [`CLI.md`](CLI.md): contrato de comandos, JSON y códigos de salida.
|
|
||||||
- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado.
|
- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado.
|
||||||
- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`.
|
|
||||||
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
|
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
|
||||||
- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes.
|
- [`content/manuales/`](content/manuales/): procedimientos operativos de esta instancia (acceso SSH, correo, Caddy, alta de administradores...).
|
||||||
- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
|
- Documentación del motor (CLI, esquemas, ADRs, `bootstrap-instance`, Runme): [fenandosr/clusterlog](https://github.com/fenandosr/clusterlog).
|
||||||
|
|
||||||
## Estado del prototipo
|
## Estado
|
||||||
|
|
||||||
La CLI compila con Go 1.23 y sus pruebas cubren creación por JSON, resolución de identidad, revisión por hash, proyección de tareas y eventos independientes. La construcción de Zola está configurada en la CI incluida. Cuando el registro ya contiene administradores activos, la CI también verifica cada commit del pull request contra `config/allowed_signers`; el scaffold vacío omite esa comprobación durante el bootstrap inicial.
|
Instancia activa del clúster MCPS. La CI (`.gitea/workflows/ci.yml`) descarga el binario y las plantillas del motor por versión fijada, sincroniza y valida el contenido, construye con Zola y publica en `clusterlog.mcps-epcm.org` al hacer push a `main`. También verifica cada commit del pull request contra `config/allowed_signers`.
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# Notas de entrega — 0.1.0
|
|
||||||
|
|
||||||
**Fecha:** 2026-08-12
|
|
||||||
|
|
||||||
## Entrega
|
|
||||||
|
|
||||||
Esta versión convierte la idea inicial en un MVP ejecutable compuesto por:
|
|
||||||
|
|
||||||
- un sitio estático Zola de consulta;
|
|
||||||
- la CLI `clusterlog` como única interfaz de escritura;
|
|
||||||
- Markdown y eventos JSON anexables como datos fuente;
|
|
||||||
- Git y firmas SSH como capa de auditoría;
|
|
||||||
- una proyección determinista para la cola de revisión, tareas y proyectos.
|
|
||||||
|
|
||||||
## Capacidades principales
|
|
||||||
|
|
||||||
- Secciones de documentación, manuales, memorias, proyectos, tareas y revisión semanal.
|
|
||||||
- Identidad de administradores mediante huella de llave pública SSH.
|
|
||||||
- Registro generado de firmantes autorizados para Git.
|
|
||||||
- Memorias con contexto, cambios, validación, riesgo y reversión.
|
|
||||||
- Revisión por todos los administradores capturados al crear la memoria.
|
|
||||||
- Invalidación automática de revisiones tras cualquier cambio de contenido.
|
|
||||||
- Proyectos con objetivo, alcance, hitos y criterio de éxito.
|
|
||||||
- Tareas con estado, prioridad, responsables, estimación, vencimiento y tiempo efectivo.
|
|
||||||
- Eventos independientes para reducir conflictos de Git.
|
|
||||||
- Contrato JSON, entrada por `stdin`, `--dry-run`, salida estable y códigos de error para agentes de IA.
|
|
||||||
- Validación de contenido, referencias, proyección y secretos probables.
|
|
||||||
- Búsqueda local sin servicios externos.
|
|
||||||
- CI con pruebas, detector de carreras, validación Zola, construcción y control de firmas.
|
|
||||||
|
|
||||||
## Decisiones relevantes
|
|
||||||
|
|
||||||
- El navegador no modifica estado en el MVP; toda escritura pasa por la CLI y por Git.
|
|
||||||
- La huella pública atribuye una identidad registrada, pero sólo una firma Git verificada demuestra posesión autorizada de la llave.
|
|
||||||
- Cada revisión está ligada al SHA-256 de la versión exacta de la memoria.
|
|
||||||
- La lista de revisores se captura al crear la memoria para que altas futuras no reabran historia antigua.
|
|
||||||
- El tema `zola.386` aporta estilos y recursos, mientras las plantillas se mantienen localmente para aislar cambios de compatibilidad de Zola.
|
|
||||||
|
|
||||||
## Inicio recomendado
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make theme
|
|
||||||
make build
|
|
||||||
./bin/clusterlog --root . sync
|
|
||||||
./bin/clusterlog --root . validate --skip-zola
|
|
||||||
zola serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Antes de uso operativo, complete los pendientes de producción descritos en `HOJA_DE_PROYECTO.md`, `SECURITY.md` y `VALIDATION_REPORT.md`.
|
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# RUNBOOK
|
||||||
|
|
||||||
|
Punto de entrada de la documentación ejecutable de esta **instancia** de
|
||||||
|
`clusterlog` (MCPS). Cada celda envuelve un comando canónico existente
|
||||||
|
(`make`, `./bin/clusterlog`, un script en `scripts/`) — ninguna celda
|
||||||
|
contiene lógica propia. El desarrollo del motor mismo (compilar, probar,
|
||||||
|
`bootstrap-instance`) vive en
|
||||||
|
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); ver
|
||||||
|
[`runbooks/README.md`](runbooks/README.md) para la convención completa de
|
||||||
|
nombres y etiquetas.
|
||||||
|
|
||||||
|
Este archivo funciona igual con o sin Runme instalado: cada bloque es un
|
||||||
|
comando de shell normal que también puede copiarse y ejecutarse a mano.
|
||||||
|
|
||||||
|
## Runbooks disponibles
|
||||||
|
|
||||||
|
- [`runbooks/weekly-review.md`](runbooks/weekly-review.md) — versión ejecutable de la reunión semanal ([`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md) conserva la agenda).
|
||||||
|
- [`runbooks/_template.md`](runbooks/_template.md) — plantilla para runbooks nuevos.
|
||||||
|
|
||||||
|
## Comprobaciones rápidas
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-runme-preflight","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
set -euo pipefail
|
||||||
|
test -f zola.toml && test -f .clusterlog-version || { echo "ejecute esto desde la raíz de la instancia" >&2; exit 1; }
|
||||||
|
echo "clusterlog-version: $(cat .clusterlog-version)"
|
||||||
|
if command -v zola >/dev/null 2>&1; then echo "zola: $(zola --version)"; else echo "zola: no instalado (validate --skip-zola sigue funcionando)"; fi
|
||||||
|
if command -v runme >/dev/null 2>&1; then echo "runme: $(runme --version)"; else echo "runme: no instalado (make runme-install)"; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-sync-check","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
make sync-check
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-validate-local","tag":"role-operator,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
make validate-local
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-site-build","tag":"role-operator,env-local,risk-read-only","interactive":"false"}
|
||||||
|
make site
|
||||||
|
```
|
||||||
|
|
||||||
|
`clusterlog-site-build` no lleva `ci-safe`: descarga el tema y las plantillas
|
||||||
|
del motor por red (`make theme templates`). La CI (`.gitea/workflows/ci.yml`)
|
||||||
|
ya construye el sitio en su propio paso; repetirlo aquí sería una segunda
|
||||||
|
copia de la misma lógica, no una celda adicional útil.
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-review-mine","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
|
||||||
|
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY con la ruta a su llave pública}"
|
||||||
|
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
|
||||||
|
```
|
||||||
|
|
||||||
|
`clusterlog-review-mine` tampoco lleva `ci-safe`: `--mine` resuelve identidad
|
||||||
|
a partir de una llave SSH personal, y CI no tiene ni debe tener acceso a
|
||||||
|
ninguna llave privada real.
|
||||||
|
|
||||||
|
```sh {"name":"clusterlog-verify-head","tag":"role-reviewer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
./bin/clusterlog --root . --json verify commit HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
|
||||||
|
Localmente, fuera de este runner, `runme run --filename RUNBOOK.md --tag
|
||||||
|
ci-safe --all --skip-prompts` funciona normalmente. En este repositorio,
|
||||||
|
`make runme-ci` en cambio corre `scripts/runme-ci.sh`, que para cada celda
|
||||||
|
`ci-safe` extrae su script con `runme run --dry-run <celda>` (nunca ejecuta
|
||||||
|
nada por sí solo) y lo corre con `bash`, deteniéndose en el primer fallo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runme run --filename RUNBOOK.md --dry-run clusterlog-runme-preflight | bash
|
||||||
|
runme run --filename RUNBOOK.md --dry-run clusterlog-sync-check | bash
|
||||||
|
runme run --filename RUNBOOK.md --dry-run clusterlog-validate-local | bash
|
||||||
|
runme run --filename RUNBOOK.md --dry-run clusterlog-verify-head | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Tanto `runme run --all` como invocar cada celda por nombre (sin `--all`)
|
||||||
|
abortan de inmediato en el runner real de esta instancia
|
||||||
|
("could not execute command: aborted", justo al entrar a "Parsing
|
||||||
|
files..."), algo que no se pudo reproducir en ninguna terminal local. Sólo
|
||||||
|
`--dry-run` evita esa ruta de código en ambos lados — ver `scripts/runme-ci.sh`
|
||||||
|
para el detalle completo, incluida la comprobación real
|
||||||
|
(`clusterlog-verify-head` corriendo y devolviendo `valid: true` de verdad).
|
||||||
|
|
||||||
|
Nunca ejecute `runme run --all --skip-prompts` sobre este archivo ni sobre
|
||||||
|
`runbooks/weekly-review.md`: sin filtrar por nombre o etiqueta, `--all`
|
||||||
|
también incluiría celdas que mutan estado real de esta instancia. Ésas
|
||||||
|
deben correrse una por una, a mano, después de revisar sus
|
||||||
|
prerrequisitos.
|
||||||
+38
@@ -121,6 +121,44 @@ Ante contenido sensible o firma cuestionable:
|
|||||||
6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro;
|
6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro;
|
||||||
7. actualice manuales y controles preventivos.
|
7. actualice manuales y controles preventivos.
|
||||||
|
|
||||||
|
## Documentación ejecutable (Runme)
|
||||||
|
|
||||||
|
`RUNBOOK.md` y `runbooks/**/*.md` permiten abrir procedimientos como
|
||||||
|
notebooks ejecutables (ver `docs/RUNME.md` y ADR-0004 en el
|
||||||
|
[repositorio del motor](https://github.com/fenandosr/clusterlog)). Esto no
|
||||||
|
cambia el modelo de confianza: se agregan las siguientes reglas.
|
||||||
|
|
||||||
|
- Ejecutar una celda de Runme equivale exactamente a ejecutar el comando
|
||||||
|
local con los permisos del usuario que la corre. No hay una elevación ni
|
||||||
|
un sandbox adicional.
|
||||||
|
- El usuario debe leer una celda antes de ejecutarla, igual que revisaría
|
||||||
|
cualquier comando pegado desde un chat o un documento.
|
||||||
|
- Un agente de IA no puede activar una celda `risk-mutating`/`risk-destructive`
|
||||||
|
sin autorización explícita del usuario para esa operación puntual — igual
|
||||||
|
que con cualquier otro comando mutante.
|
||||||
|
- Una etiqueta (`ci-safe`, `role-*`, `risk-*`) es metadato de clasificación,
|
||||||
|
**no** un control de acceso. `interactive` y `excludeFromRunAll` son
|
||||||
|
defensas adicionales que Runme sí respeta técnicamente, pero tampoco son
|
||||||
|
autorización: son mecanismos para reducir el riesgo de un "Run All"
|
||||||
|
accidental, no una barrera de permisos.
|
||||||
|
- Runme no debe leer, exportar ni copiar ninguna llave privada. Ninguna
|
||||||
|
celda de este repositorio lo hace ni debe hacerlo.
|
||||||
|
- No se guardan outputs de celdas con secretos. Los Session Outputs de
|
||||||
|
Runme están desactivados por defecto en esta integración; si se habilitan
|
||||||
|
localmente, no deben versionarse ni compartirse.
|
||||||
|
- Runme Cloud, Gists y cualquier servicio remoto de Runme quedan fuera de
|
||||||
|
alcance. `runme open` (servidor web local) no debe exponerse en `0.0.0.0`.
|
||||||
|
- La ejecución de celdas desde el sitio Zola publicado está fuera de
|
||||||
|
alcance: Zola no construye `runbooks/`, y no existe ningún mecanismo para
|
||||||
|
correr Runme desde el navegador.
|
||||||
|
- Las operaciones productivas reales (RBAC, `sudo`, gestores de secretos,
|
||||||
|
kubeconfig, proveedor cloud, controles corporativos) siguen siendo
|
||||||
|
responsabilidad de esas herramientas. Runme nunca las reemplaza ni las
|
||||||
|
reimplementa.
|
||||||
|
- Un agente debe detenerse ante errores de identidad, firma o validación
|
||||||
|
también dentro de un runbook, igual que en cualquier otro flujo de
|
||||||
|
`clusterlog`.
|
||||||
|
|
||||||
## Reporte de vulnerabilidades
|
## Reporte de vulnerabilidades
|
||||||
|
|
||||||
No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales.
|
No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Compatibilidad con `zola.386`
|
|
||||||
|
|
||||||
## Objetivo
|
|
||||||
|
|
||||||
Conservar la estética retro y responsiva de `lopes/zola.386` sin acoplar la lógica del producto a plantillas históricas del tema.
|
|
||||||
|
|
||||||
## Revisión fijada
|
|
||||||
|
|
||||||
El script `scripts/bootstrap-theme.sh` usa por defecto:
|
|
||||||
|
|
||||||
```text
|
|
||||||
d9c9726435d1347341f2171f9aa751291f1db93d
|
|
||||||
```
|
|
||||||
|
|
||||||
Puede cambiarse de forma explícita:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ZOLA386_REF=<commit> ./scripts/bootstrap-theme.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Para una copia local u offline:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ZOLA386_SOURCE=/ruta/a/zola.386 ./scripts/bootstrap-theme.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Qué se importa
|
|
||||||
|
|
||||||
- `sass/`
|
|
||||||
- `static/`
|
|
||||||
- `theme.toml`
|
|
||||||
- licencia y README upstream, cuando existen
|
|
||||||
|
|
||||||
## Qué no se importa
|
|
||||||
|
|
||||||
Las plantillas upstream no se copian. El proyecto mantiene plantillas locales en `templates/` para:
|
|
||||||
|
|
||||||
- usar el contrato actual de Zola/Tera;
|
|
||||||
- cargar `data/generated/site_state.json`;
|
|
||||||
- presentar memorias, proyectos, tareas y revisión;
|
|
||||||
- controlar navegación, búsqueda y accesibilidad;
|
|
||||||
- evitar que una actualización visual cambie la semántica de datos.
|
|
||||||
|
|
||||||
Zola da precedencia a las plantillas locales sobre las del tema, por lo que se conserva el tema como fuente visual y no como capa de aplicación.
|
|
||||||
|
|
||||||
## Actualización del tema
|
|
||||||
|
|
||||||
1. Seleccione un commit, no una rama flotante.
|
|
||||||
2. Ejecute el script con `ZOLA386_REF`.
|
|
||||||
3. Revise visualmente todas las secciones.
|
|
||||||
4. Ejecute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
make sync
|
|
||||||
clusterlog --root . validate --require-zola
|
|
||||||
zola build
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Compare HTML, búsqueda y responsive.
|
|
||||||
6. Registre la nueva revisión en un commit firmado.
|
|
||||||
|
|
||||||
## Búsqueda en español
|
|
||||||
|
|
||||||
El proyecto genera `search_index.es.json` con `elasticlunr_json`, pero no carga el motor Elasticlunr en el navegador. `static/js/ops-search.js` lee los documentos del índice y hace una coincidencia local normalizada, sin depender de stemmers externos ni de una CDN. Para el tamaño esperado del MVP, esto favorece portabilidad y funcionamiento offline.
|
|
||||||
|
|
||||||
## Zola fijado
|
|
||||||
|
|
||||||
La CI usa Zola `0.23.3`. Actualizar Zola requiere una pull request separada que incluya:
|
|
||||||
|
|
||||||
- revisión de notas de migración;
|
|
||||||
- `zola check`;
|
|
||||||
- construcción completa;
|
|
||||||
- prueba de búsqueda;
|
|
||||||
- prueba visual de portada, secciones, páginas, taxonomías y revisión.
|
|
||||||
|
|
||||||
## Licencias
|
|
||||||
|
|
||||||
La licencia del tema se conserva dentro de `themes/zola.386/LICENSE` cuando el script lo prepara. La licencia del proyecto no sustituye ni elimina las obligaciones del componente upstream.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Informe de validación del MVP
|
|
||||||
|
|
||||||
**Producto:** Bitácora Operativa del Clúster
|
|
||||||
**Versión:** 0.1.0
|
|
||||||
**Fecha de corte:** 2026-08-12, zona `America/Mexico_City`
|
|
||||||
**Plataforma local:** Linux `x86_64`, Go 1.23.2, Git 2.47.3, Node.js 22.16.0
|
|
||||||
|
|
||||||
## Resultado ejecutivo
|
|
||||||
|
|
||||||
El prototipo de la CLI quedó compilado y todas las comprobaciones ejecutables en el entorno local terminaron correctamente. El flujo integral validó el ciclo administrador → memoria → revisión por dos personas → invalidación por edición → nueva revisión → proyecto → tarea → tiempo → cierre.
|
|
||||||
|
|
||||||
La construcción real del sitio con Zola y una firma SSH válida no pudieron ejecutarse en este entorno porque no estaban instalados `zola` ni `ssh-keygen`. Ambos controles están definidos en la canalización de GitHub Actions y deben ejecutarse antes de promover el repositorio a piloto.
|
|
||||||
|
|
||||||
## Comprobaciones ejecutadas
|
|
||||||
|
|
||||||
| Comprobación | Comando | Resultado |
|
|
||||||
|---|---|---|
|
|
||||||
| Formato Go | `test -z "$(gofmt -l cmd internal)"` | Correcto |
|
|
||||||
| Pruebas Go | `go test ./...` | Correcto |
|
|
||||||
| Detector de carreras | `go test -race ./...` | Correcto |
|
|
||||||
| Cobertura | `go test -cover ./...` | 32.9 % en `internal/clusterlog` |
|
|
||||||
| Análisis estático | `go vet ./...` | Correcto |
|
|
||||||
| Compilación | `make build` | Correcto |
|
|
||||||
| Flujo integral | `make e2e` | Correcto |
|
|
||||||
| Contratos rápidos | `make smoke` | Correcto |
|
|
||||||
| JavaScript | `node --check static/js/ops-search.js` | Correcto |
|
|
||||||
| Scripts shell | `bash -n scripts/*.sh` | Correcto |
|
|
||||||
| Proyección | `clusterlog sync` | Sin diferencias |
|
|
||||||
| Reproducibilidad | `clusterlog sync --check` | Correcto |
|
|
||||||
| Validación local | `clusterlog validate --skip-zola` | Válido, sin errores ni advertencias |
|
|
||||||
| Firma no autorizada | prueba negativa de `verify-signatures-ci.sh` | Rechazada correctamente |
|
|
||||||
| Bootstrap sin administradores | `verify-signatures-ci.sh` | Omisión controlada correcta |
|
|
||||||
|
|
||||||
## Escenario E2E validado
|
|
||||||
|
|
||||||
1. Registra el primer administrador como `owner` y un segundo administrador.
|
|
||||||
2. Crea una memoria con política `all-active`.
|
|
||||||
3. Comprueba que ambos administradores aparecen como revisores requeridos.
|
|
||||||
4. Registra cada revisión en un archivo de evento independiente.
|
|
||||||
5. Comprueba que la memoria desaparece de la cola al completar las revisiones.
|
|
||||||
6. Modifica la memoria, recalcula su hash y comprueba que las revisiones anteriores dejan de aplicar.
|
|
||||||
7. Registra nuevamente las dos revisiones.
|
|
||||||
8. Crea un proyecto activo y una tarea vinculada.
|
|
||||||
9. Registra inicio de tiempo, ajuste manual y cierre de la tarea.
|
|
||||||
10. Comprueba la proyección final, la independencia de eventos y la regeneración determinista.
|
|
||||||
|
|
||||||
## Estado inicial de demostración
|
|
||||||
|
|
||||||
La proyección incluida contiene:
|
|
||||||
|
|
||||||
- `0` administradores activos;
|
|
||||||
- `0` memorias pendientes de revisión;
|
|
||||||
- `1` tarea abierta de demostración;
|
|
||||||
- `1` proyecto planeado de demostración.
|
|
||||||
|
|
||||||
La memoria de ejemplo declara `review_required = false` para no contaminar la primera reunión real.
|
|
||||||
|
|
||||||
## Binario entregado
|
|
||||||
|
|
||||||
- Plataforma: Linux `amd64`.
|
|
||||||
- Tipo: ejecutable ELF de 64 bits, enlazado estáticamente.
|
|
||||||
- Ruta: `bin/clusterlog`.
|
|
||||||
- SHA-256 al cierre de esta validación: se publica en `SHA256SUMS`.
|
|
||||||
|
|
||||||
## Comprobaciones delegadas a CI
|
|
||||||
|
|
||||||
La canalización `.github/workflows/ci.yml` fija Zola 0.23.3 y ejecuta:
|
|
||||||
|
|
||||||
- descarga del tema fijado a una revisión concreta;
|
|
||||||
- validación con `--require-zola`;
|
|
||||||
- construcción completa con `zola build`;
|
|
||||||
- verificación de todos los commits de un pull request contra `config/allowed_signers` cuando ya existen administradores activos.
|
|
||||||
|
|
||||||
Estas comprobaciones están configuradas, pero este informe no afirma que GitHub Actions ya haya ejecutado una corrida. Debe conservarse como requisito de aceptación del primer pull request.
|
|
||||||
|
|
||||||
## Comprobaciones pendientes antes del piloto
|
|
||||||
|
|
||||||
1. Ejecutar la CI en el repositorio remoto y conservar una corrida verde.
|
|
||||||
2. Crear y verificar al menos un commit firmado con una llave SSH real autorizada.
|
|
||||||
3. Revisar el sitio generado en escritorio y móvil detrás del mecanismo de acceso interno elegido.
|
|
||||||
4. Probar el flujo con al menos dos administradores reales y una memoria no demostrativa.
|
|
||||||
5. Subir la cobertura de lógica crítica, especialmente errores de Git, validación de contenido y colisiones de archivos.
|
|
||||||
6. Ejecutar revisión de amenazas y acordar protección de rama, propietarios de código y retención.
|
|
||||||
|
|
||||||
## Repetición local
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build
|
|
||||||
make test
|
|
||||||
make vet
|
|
||||||
make e2e
|
|
||||||
make smoke
|
|
||||||
node --check static/js/ops-search.js
|
|
||||||
for f in scripts/*.sh; do bash -n "$f"; done
|
|
||||||
./bin/clusterlog --root . --json sync --check
|
|
||||||
./bin/clusterlog --root . --json validate --skip-zola
|
|
||||||
```
|
|
||||||
|
|
||||||
En un equipo con Zola, OpenSSH y acceso al tema:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make theme
|
|
||||||
make site
|
|
||||||
```
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"example.org/bitacora-cluster/internal/clusterlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fs := flag.NewFlagSet("clusterlog", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
root := fs.String("root", "", "raíz del proyecto")
|
|
||||||
jsonMode := fs.Bool("json", false, "salida JSON estable")
|
|
||||||
sshKey := fs.String("ssh-key", "", "llave pública SSH o ruta base")
|
|
||||||
commit := fs.Bool("commit", false, "crea un commit Git SSH firmado")
|
|
||||||
dryRun := fs.Bool("dry-run", false, "valida sin escribir")
|
|
||||||
fs.Usage = func() {
|
|
||||||
fmt.Fprintln(os.Stderr, "Uso: clusterlog [opciones globales] <comando> [opciones]")
|
|
||||||
fmt.Fprintln(os.Stderr, "Ejecute clusterlog help para ver los comandos.")
|
|
||||||
fs.PrintDefaults()
|
|
||||||
}
|
|
||||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
|
||||||
os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, clusterlog.NewError(clusterlog.ExitUsage, "invalid_global_flags", err.Error(), nil)))
|
|
||||||
}
|
|
||||||
app := clusterlog.New(clusterlog.Options{
|
|
||||||
Root: *root, JSON: *jsonMode, SSHKey: *sshKey, Commit: *commit, DryRun: *dryRun,
|
|
||||||
Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog",
|
|
||||||
})
|
|
||||||
if err := app.Run(fs.Args()); err != nil {
|
|
||||||
os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
+++
|
||||||
|
title = "Configurar acceso al servicio Git"
|
||||||
|
description = "Activar su cuenta, agregar su llave SSH y confirmar acceso clonando el repositorio de ejemplo."
|
||||||
|
date = 2026-08-18T05:25:20Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "run-20260818-ba0a96-configurar-acceso-al-servicio-git"
|
||||||
|
template = "page.html"
|
||||||
|
weight = 20
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["manuales"]
|
||||||
|
tags = ["acceso", "git", "gitea", "ssh"]
|
||||||
|
systems = ["git"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "RUN-20260818-BA0A96"
|
||||||
|
kind = "manuales"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- Su cuenta en el servicio Git ya está creada; recibió por correo el usuario y una contraseña temporal.
|
||||||
|
- Su acceso a la VPN del clúster ya configurado (el mismo que usa para `ssh mcps` y los demás servicios internos).
|
||||||
|
- Su llave SSH personal, la misma que ya usa para acceder al clúster. No hace falta crear una llave nueva.
|
||||||
|
|
||||||
|
El servicio Git vive en la misma infraestructura que el clúster con su propio inicio de sesión web y su propio puerto SSH, por lo que no es lo mismo que iniciar sesión con `ssh mcps`.
|
||||||
|
|
||||||
|
## 1. Iniciar sesión y cambiar la contraseña
|
||||||
|
|
||||||
|
1. Entre a `https://git.mcps-epcm.org` con el usuario y la contraseña temporal del correo.
|
||||||
|
2. El sistema le va a pedir que la cambie en ese primer inicio de sesión. Si no se lo pide automáticamente, vaya a su avatar (arriba a la derecha) → **Configuración** → **Cuenta** y cambie la contraseña ahí.
|
||||||
|
|
||||||
|
La contraseña sólo sirve para la interfaz web. Clonar, subir o bajar cambios por Git usa su llave SSH, que se configura en el siguiente paso.
|
||||||
|
|
||||||
|
## 2. Agregar su llave pública SSH a la cuenta
|
||||||
|
|
||||||
|
1. Obtenga el contenido de su llave pública:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
2. En la interfaz web: avatar → **Configuración** → **Llaves SSH / GPG** → **Agregar llave**.
|
||||||
|
3. Péguela completa (empieza con `ssh-ed25519` o `ssh-rsa`), póngale un nombre que la identifique (por ejemplo, usuario@equipo) y guarde.
|
||||||
|
|
||||||
|
## 3. Confirmar que la llave quedó activa
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 git@git.mcps-epcm.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Si todo está bien configurado, va a ver un mensaje similar a:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hi there, <su-usuario>! You've successfully authenticated with the key named "...", but Gitea does not provide shell access.
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese mensaje **es el resultado esperado** — no es un error; Gitea sólo confirma que lo reconoció. Si en cambio ve `Permission denied (publickey)`, revise la sección de solución de problemas más abajo antes de continuar.
|
||||||
|
|
||||||
|
## 4. Confirmar acceso: clonar el repositorio de ejemplo
|
||||||
|
|
||||||
|
Con la llave ya confirmada, clone el repositorio de ejemplo (`clusterlog`) para comprobar que puede traer código del servicio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.mcps-epcm.org:2222/fernando/clusterlogs.git
|
||||||
|
cd clusterlogs
|
||||||
|
git log --oneline -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el clonado termina sin errores y `git log` muestra los últimos commits, su acceso quedó confirmado.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
- `ssh -p 2222 git@git.mcps-epcm.org` responde con el saludo de Gitea (no "Permission denied").
|
||||||
|
- `git clone ssh://git@git.mcps-epcm.org:2222/fernando/clusterlogs.git` termina sin errores.
|
||||||
|
- `cd clusterlogs && git log --oneline -3` muestra commits reales del repositorio.
|
||||||
|
|
||||||
|
Cada miembro debe completar estos tres puntos para marcarlo en la reunión.
|
||||||
|
|
||||||
|
## Solución de problemas
|
||||||
|
|
||||||
|
- **`Permission denied (publickey)`**: confirme que agregó la llave *pública* (`.pub`) completa, sin saltos de línea de más, y que es la misma llave que tiene cargada localmente (`ssh-add -l` para ver cuáles está ofreciendo su agente SSH).
|
||||||
|
- **`Could not resolve hostname` / tiempo de espera agotado**: confirme que tiene acceso de red al clúster activo (el mismo que necesita para `ssh mcps`); el servicio sólo es alcanzable desde ahí.
|
||||||
|
- **El clonado pide contraseña en vez de usar la llave**: revise que la URL use el prefijo `ssh://git@...:2222/` tal como está arriba, no una URL `https://`.
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
+++
|
||||||
|
title = "Caddy: arquitectura de dos capas y alta de dominios con DNS-01/Route53"
|
||||||
|
description = "Las dos capas de Caddy, cómo agregar un dominio interno con certificado real vía DNS-01/Route53, y los dos gotchas conocidos de propagación."
|
||||||
|
date = 2026-08-19T20:25:10Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "run-20260819-a3b63b-caddy-arquitectura-de-dos-capas-y-alta-de-dominios-con-dns-01-route53"
|
||||||
|
template = "page.html"
|
||||||
|
weight = 26
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["manuales"]
|
||||||
|
tags = ["caddy", "dns-01", "route53", "tls"]
|
||||||
|
systems = ["caddy"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "RUN-20260819-A3B63B"
|
||||||
|
kind = "manuales"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Arquitectura: dos Caddys, dos capas
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet ──HTTPS(443)──> Caddy #1 (mcps-vpn-server, IP pública)
|
||||||
|
│ termina TLS real (Let's Encrypt)
|
||||||
|
│ reenvía HTTP plano por el túnel WireGuard
|
||||||
|
▼
|
||||||
|
mcps-hpc-01 (10.8.0.18)
|
||||||
|
│
|
||||||
|
Caddy #2 (ahí mismo)
|
||||||
|
│ segundo enrutamiento por Host header
|
||||||
|
┌──────────────┼──────────────┬─────────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
nginx/Apache/gunicorn/Grafana/etc (los backends reales)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Config de Caddy #1: `/etc/caddy/Caddyfile` + `/etc/caddy/tenants/*.caddy`.
|
||||||
|
- Config de Caddy #2: `/mnt/cephfs/hot/caddy/Caddyfile` (montado en el contenedor vía `container-caddy.service`, systemd+podman en `.18`).
|
||||||
|
- `mcps-net` (red podman en `.18`) tiene `dns_enabled: false` — por eso el Caddyfile de ahí usa **IPs fijas**, no nombres de contenedor. Si un contenedor se recrea sin `--ip` fijo, su IP cambia y hay que actualizar el Caddyfile a mano. Ponga siempre `--ip 10.89.0.X` fijo a cualquier contenedor nuevo que Caddy vaya a referenciar ahí.
|
||||||
|
|
||||||
|
## Dominios VPN-only vs. públicos
|
||||||
|
|
||||||
|
Los dominios internos (Cockpit, Zammad, pgAdmin, RStudio, Jupyter, Snipe-IT, etc.) apuntan en Route53 a propósito a `10.8.0.18` (IP privada, solo alcanzable por VPN), nunca a la IP pública. Con eso, `tls internal` (certificado autofirmado) da warning de navegador — el patrón correcto es **DNS-01 vía Route53**, que da certificado real de Let's Encrypt sin necesitar reachability pública.
|
||||||
|
|
||||||
|
## Prerrequisitos (infraestructura de una sola vez, ya existe)
|
||||||
|
|
||||||
|
- Usuario IAM dedicado `caddy-route53-dns01`, con permisos mínimos: `route53:ChangeResourceRecordSets` y `route53:ListResourceRecordSets` sobre la zona `mcps-epcm.org` (`Z05793723RVTGN4VZAAUN`), más `GetChange`/`ListHostedZones*` (globales). Nunca usar las credenciales del usuario `Administrator`.
|
||||||
|
- Imagen Caddy custom con el plugin de Route53 (la oficial no lo trae):
|
||||||
|
```dockerfile
|
||||||
|
FROM caddy:builder AS builder
|
||||||
|
RUN xcaddy build --with github.com/caddy-dns/route53
|
||||||
|
FROM caddy:latest
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
```
|
||||||
|
Imagen: `localhost/caddy-route53:latest`.
|
||||||
|
- Credenciales en `/etc/caddy-route53.env` (`root:root`, `600`), inyectadas al contenedor vía `--env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY` (forma "bare" de podman: toma el valor del entorno del proceso llamador, por eso el `EnvironmentFile=` en el systemd unit).
|
||||||
|
|
||||||
|
## Procedimiento: agregar un dominio nuevo a este patrón
|
||||||
|
|
||||||
|
1. En Route53, apunte el registro del dominio a `10.8.0.18` (VPN-only) o a la IP pública correspondiente (público).
|
||||||
|
2. En `/mnt/cephfs/hot/caddy/Caddyfile`, agregue el bloque del sitio con:
|
||||||
|
```caddy
|
||||||
|
tls {
|
||||||
|
dns route53 {
|
||||||
|
access_key_id {env.AWS_ACCESS_KEY_ID}
|
||||||
|
secret_access_key {env.AWS_SECRET_ACCESS_KEY}
|
||||||
|
}
|
||||||
|
propagation_delay 20s
|
||||||
|
propagation_timeout -1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. `caddy validate` sobre el Caddyfile antes de aplicar.
|
||||||
|
4. `caddy reload` (no reinicia el proceso, no tira el resto de sitios).
|
||||||
|
|
||||||
|
## Por qué `propagation_timeout -1` y `propagation_delay 20s`, no el default
|
||||||
|
|
||||||
|
La red de `.18` redirige por transparencia todo el tráfico DNS al resolver `132.248.204.1` (red UNAM), sin importar qué servidor le pidas — confirmado con `nslookup ... 8.8.8.8` desde dentro de un contenedor, devolviendo `NXDOMAIN` mientras el registro sí existía y propagaba bien visto desde `mcps-vpn-server`. El chequeo de propagación *propio* de Caddy (usando ese resolver secuestrado/con caché negativo viejo) se queda colgado indefinidamente.
|
||||||
|
|
||||||
|
- `propagation_timeout -1`: Caddy no verifica localmente, deja que Let's Encrypt valide directo desde su red.
|
||||||
|
- `propagation_delay 20s`: espera fija para dar tiempo real a Route53, evita condición de carrera si el registro aún no propagó del todo.
|
||||||
|
|
||||||
|
Nota: esto **no** es una ruta de red real hacia la UNAM, solo el DNS configurado en el router — la salida real a internet de `.18` es Telmex (única default route). No hay forma de salir con IP de la UNAM desde esta infraestructura.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
- `curl -v https://<dominio>.mcps-epcm.org` muestra `SSL certificate verify ok` con emisor Let's Encrypt, no un certificado autofirmado.
|
||||||
|
- El sitio responde sin advertencia de certificado en el navegador (dentro de la VPN, si es VPN-only).
|
||||||
|
|
||||||
|
## Solución de problemas
|
||||||
|
|
||||||
|
- **`Duplicate Resource Record` al reintentar DNS-01**: si se mata/reinicia Caddy a medio intento, puede quedar un registro `_acme-challenge.<dominio>` huérfano en Route53 que choca con el siguiente intento. Bórrelo a mano:
|
||||||
|
```bash
|
||||||
|
aws route53 list-resource-record-sets --hosted-zone-id Z05793723RVTGN4VZAAUN \
|
||||||
|
--query "ResourceRecordSets[?Name=='_acme-challenge.<dominio>.mcps-epcm.org.']"
|
||||||
|
# luego change-resource-record-sets con Action=DELETE y el mismo valor
|
||||||
|
```
|
||||||
|
- **El challenge se queda colgado indefinidamente**: confirme que el bloque `tls` tiene `propagation_timeout -1` — sin eso, Caddy intenta verificar la propagación usando el resolver secuestrado de la red de `.18` y nunca termina.
|
||||||
|
- **Un contenedor backend cambia de IP y el sitio deja de responder**: confirme que el contenedor se recreó con `--ip` fijo; si no, actualice el Caddyfile a la IP nueva.
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
+++
|
||||||
|
title = "Configurar acceso SSH al clúster administrado"
|
||||||
|
description = "Definir su alias personal en ~/.ssh/config para conectarse al clúster (mcps) sin exponer llaves ni rutas personales en el repositorio."
|
||||||
|
date = 2026-08-19T05:58:18Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "run-20260819-bf6dc8-configurar-acceso-ssh-al-cluster-administrado"
|
||||||
|
template = "page.html"
|
||||||
|
weight = 15
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["manuales"]
|
||||||
|
tags = ["acceso", "cluster", "ssh"]
|
||||||
|
systems = ["cluster"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "RUN-20260819-BF6DC8"
|
||||||
|
kind = "manuales"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- Ya es administrador registrado en clusterlog y tiene su llave SSH personal (la misma de siempre; no hace falta crear una nueva).
|
||||||
|
- Ya tiene acceso de red/VPN al clúster.
|
||||||
|
- Un *owner* u otro miembro del comité le confirmó, fuera de este repositorio, el usuario, el host o IP real y el puerto para conectarse. Esos datos son personales/locales y **no están documentados aquí** — sólo el procedimiento para guardarlos.
|
||||||
|
|
||||||
|
## Por qué esto no vive en el repositorio
|
||||||
|
|
||||||
|
El repositorio guarda el contrato compartido de cómo conectarse (alias, roles, qué evitar) en la skill [`acceso-cluster`](../../.claude/skills/acceso-cluster/SKILL.md), para que cualquier IA lanzada desde aquí — la suya o la de otro miembro del comité — se comporte igual. Lo que varía por persona (llave, usuario, host real, puerto) vive únicamente en su `~/.ssh/config` local, nunca en Git.
|
||||||
|
|
||||||
|
## 1. Agregar su alias en `~/.ssh/config`
|
||||||
|
|
||||||
|
Abra (o cree) `~/.ssh/config` y agregue un bloque `Host` por cada alias listado en el inventario de la skill. Para el alias principal `mcps`:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host mcps
|
||||||
|
HostName <host-o-ip-real-que-le-confirmaron>
|
||||||
|
User <su-usuario>
|
||||||
|
Port <puerto-si-no-es-22>
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Reemplace `<host-o-ip-real-que-le-confirmaron>` y `<su-usuario>` por los datos que le dieron; no los pida ni los busque en este repositorio. Ajuste la ruta de `IdentityFile` si usa una llave distinta a la registrada en `clusterlog`.
|
||||||
|
|
||||||
|
## 2. Confirmar la conexión
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh mcps
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe llegar directo a una sesión de shell en el clúster, sin que le pida contraseña. Si en cambio ve `Permission denied (publickey)` o `Could not resolve hostname`, revise la sección de solución de problemas.
|
||||||
|
|
||||||
|
## 3. Cómo lo usa un agente de IA
|
||||||
|
|
||||||
|
Cualquier IA (Claude Code u otra) lanzada desde este repositorio debe ejecutar `ssh mcps` — o el alias que corresponda — y nunca construir la conexión a mano con `-i` y una ruta de llave. Ese contrato completo, incluyendo qué comandos evitar y cuándo pedir confirmación antes de actuar sobre el clúster, está en la skill `acceso-cluster`, que se carga sola en cada sesión de Claude Code iniciada aquí. Este manual sólo cubre la configuración local que hace posible que ese contrato funcione igual para todos.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
- `ssh mcps` conecta sin pedir contraseña y sin errores.
|
||||||
|
- `ssh -v mcps` (si hace falta depurar) muestra que ofreció la llave correcta, sin haber impreso ni copiado su contenido.
|
||||||
|
- Una IA lanzada desde este repositorio, al pedirle conectarse al clúster, usa `ssh mcps` por su cuenta en vez de preguntar por una ruta de llave o una IP.
|
||||||
|
|
||||||
|
## Mantenimiento
|
||||||
|
|
||||||
|
Cuando el clúster incorpore un host o alias nuevo, dos pasos separados:
|
||||||
|
|
||||||
|
1. Alguien agrega la fila correspondiente (alias, rol, notas) al inventario compartido en `acceso-cluster/SKILL.md`, vía pull request normal.
|
||||||
|
2. Cada administrador agrega su propio bloque `Host` para ese alias en su `~/.ssh/config` local, con sus datos reales.
|
||||||
|
|
||||||
|
## Solución de problemas
|
||||||
|
|
||||||
|
- **`Permission denied (publickey)`**: confirme que `IdentityFile` en su bloque `Host` apunta a la llave privada correcta y que esa llave está registrada como administrador en `clusterlog` (`clusterlog --ssh-key ... identity`). Revise también `ssh-add -l` si usa un agente.
|
||||||
|
- **`Could not resolve hostname` / tiempo de espera agotado**: confirme `HostName` y que tiene la VPN o red de administración activa.
|
||||||
|
- **Le pide contraseña en vez de usar la llave**: revise que `User` y `Port` en el bloque `Host` sean los que le confirmaron, y que la llave pública esté autorizada en ese host.
|
||||||
|
- **Un agente de IA intenta armar `ssh -i ...` en vez de usar el alias**: es una señal de que la skill `acceso-cluster` no se cargó o fue ignorada; repórtelo, no complete la conexión manualmente en su lugar dentro de la sesión.
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
+++
|
||||||
|
title = "Administrar el servidor de correo (docker-mailserver)"
|
||||||
|
description = "Dónde vive el servicio, cómo administrar cuentas y cómo enviar correo de prueba desde terminal."
|
||||||
|
date = 2026-08-19T20:25:06Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "run-20260819-e3a1c3-administrar-el-servidor-de-correo-docker-mailserver"
|
||||||
|
template = "page.html"
|
||||||
|
weight = 25
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["manuales"]
|
||||||
|
tags = ["correo", "docker-mailserver", "swaks"]
|
||||||
|
systems = ["mail-server"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "RUN-20260819-E3A1C3"
|
||||||
|
kind = "manuales"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- Acceso SSH a `mcps-vpn-server` (el host que corre el contenedor real de correo).
|
||||||
|
- Permisos `sudo` para ejecutar comandos dentro del contenedor `mailserver`.
|
||||||
|
|
||||||
|
## Dónde vive
|
||||||
|
|
||||||
|
- Contenedor `ghcr.io/docker-mailserver/docker-mailserver`, gestionado por `container-mailserver.service` (systemd + podman) en **`mcps-vpn-server`** (no en `mcps-hpc-01`).
|
||||||
|
- Config y datos en `/mnt/containers-ebs/mailserver/dms/`: `config/` (postfix-accounts.cf, postfix-main.cf, opendkim, etc.), `mail-data/` (Maildir reales, por ejemplo `mail-data/mcps-epcm.org/<usuario>/`), `mail-logs/` (`mail.log` + 4 rotaciones, ~1 mes de retención).
|
||||||
|
- Expuesto en `mail.mcps-epcm.org`, que resuelve directo a la IP pública del host.
|
||||||
|
|
||||||
|
## Procedimiento: administrar cuentas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo podman exec mailserver setup email list # listar cuentas + tamaño
|
||||||
|
sudo podman exec mailserver setup email update <correo> [<pass>] # cambiar password
|
||||||
|
sudo podman exec mailserver setup email del <correo> # dar de baja (borra Maildir)
|
||||||
|
sudo podman exec mailserver setup email restrict add|del send|receive <correo> # deshabilitar sin borrar (reversible)
|
||||||
|
sudo podman exec mailserver setup email restrict list send|receive # ver quién está restringido
|
||||||
|
```
|
||||||
|
|
||||||
|
`restrict` es la forma de "bajar" una cuenta sin perder el Maildir ni tener que volver a crearla después — útil cuando no se quiere un borrado definitivo todavía (por ejemplo, en un offboarding en curso).
|
||||||
|
|
||||||
|
## Procedimiento: mandar correo de prueba desde terminal
|
||||||
|
|
||||||
|
Con `swaks` (instalado vía EPEL en `mcps-vpn-server`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaks --to dest@ejemplo.com --from soporte@mcps-epcm.org \
|
||||||
|
--server mail.mcps-epcm.org:587 --helo mcps-epcm.org \
|
||||||
|
--auth LOGIN --auth-user soporte@mcps-epcm.org --auth-password '<pass>' \
|
||||||
|
--tls --header 'Subject: asunto' --body 'texto'
|
||||||
|
```
|
||||||
|
|
||||||
|
Config reusable en `~/.swaks/<cuenta>.conf` (uno por cuenta, `chmod 600`, `swaks --config ~/.swaks/soporte.conf ...`). Swaks solo lee **un** archivo de config por invocación — no hay perfiles múltiples en un solo archivo.
|
||||||
|
|
||||||
|
## Configuración de cliente (IMAP/SMTP manual)
|
||||||
|
|
||||||
|
- IMAP: `mail.mcps-epcm.org:993`, SSL/TLS
|
||||||
|
- SMTP: `mail.mcps-epcm.org:587`, STARTTLS, con autenticación
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
- `sudo podman exec mailserver setup email list` muestra la cuenta esperada con un tamaño de buzón razonable.
|
||||||
|
- Un `swaks` de prueba hacia una cuenta propia entrega sin rechazo.
|
||||||
|
- `sudo podman exec mailserver setup email restrict list send|receive` no muestra cuentas restringidas que deberían estar activas.
|
||||||
|
|
||||||
|
## Solución de problemas
|
||||||
|
|
||||||
|
- **`swaks` falla con autenticación**: confirme que la contraseña usada es la vigente (`setup email update` la rota); una rotación reciente invalida clientes ya configurados con la contraseña anterior.
|
||||||
|
- **Algo sugiere que el contenedor vive en `mcps-hpc-01`**: es incorrecto desde antes del 2026-06-24; el contenedor real está en `mcps-vpn-server`. Puede seguir existiendo un contenedor viejo detenido (`Exited`) en `mcps-hpc-01` — no reactivarlo pensando que es el servicio real.
|
||||||
|
- **Una cuenta no manda ni recibe**: revise si está en la lista de restringidas (`setup email restrict list send|receive`) antes de asumir que es un problema del servidor.
|
||||||
@@ -43,3 +43,39 @@ El primer administrador recibe automáticamente el rol `owner`. Después, sólo
|
|||||||
./bin/clusterlog validate --skip-zola
|
./bin/clusterlog validate --skip-zola
|
||||||
git log --show-signature -1
|
git log --show-signature -1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Bonus: registrar al segundo administrador
|
||||||
|
|
||||||
|
Sólo un `owner` activo puede hacer esto — corra el comando con **su propia** llave (la del primer administrador, ya registrado arriba), nunca con la del segundo.
|
||||||
|
|
||||||
|
### Prerrequisitos
|
||||||
|
|
||||||
|
- Usted ya es `owner` activo (procedimiento anterior completado).
|
||||||
|
- El segundo administrador ya tiene su propia llave SSH y le compartió únicamente el archivo **público** (`.pub`) — nunca la privada.
|
||||||
|
|
||||||
|
### Procedimiento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/clusterlog --ssh-key ~/.ssh/id_ed25519.pub --commit admin add \
|
||||||
|
--id beto --name "Beto Salazar" --email beto@example.org \
|
||||||
|
--key /ruta/a/la/llave/del-segundo-administrador.pub \
|
||||||
|
--roles admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--roles owner,admin` sólo si el segundo administrador también debe poder registrar administradores futuros; de lo contrario, `admin` alcanza.
|
||||||
|
|
||||||
|
### Verificación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/clusterlog --json admin list --active
|
||||||
|
git log --show-signature -1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `admin list --active` muestra a ambos administradores.
|
||||||
|
- Desde su propia máquina, el segundo administrador confirma su propia identidad:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/clusterlog --ssh-key ~/.ssh/id_ed25519.pub identity
|
||||||
|
```
|
||||||
|
|
||||||
|
`data/admins.json` y `config/allowed_signers` se actualizan juntos en el mismo commit — no hace falta un paso aparte para autorizar sus futuras firmas.
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
+++
|
||||||
|
title = "Corregir despliegue de clusterlog en Caddy y dar persistencia al runner de Gitea Actions"
|
||||||
|
description = "Bloque de Caddy faltante para clusterlog.mcps-epcm.org agregado; runner nativo de Gitea Actions pasado de proceso manual a systemd."
|
||||||
|
date = 2026-08-19T18:56:10Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260819-51dea9-corregir-despliegue-de-clusterlog-en-caddy-y-dar-persistencia-al-runner-de-gitea-actions"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["caddy", "clusterlog", "gitea-actions", "systemd"]
|
||||||
|
systems = ["caddy", "gitea-runner", "mcps-hpc-01"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260819-51DEA9"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "medium"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
clusterlog.mcps-epcm.org (contenedor nginx clusterlog, 10.89.0.4:80 en mcps-net) y su DNS ya existían, pero la página no cargaba: faltaba el bloque correspondiente en /mnt/cephfs/hot/caddy/Caddyfile. Al revisar la infraestructura se encontró además que el runner de Gitea Actions (act_runner v0.2.13, ya registrado contra Gitea, uuid 5844d409-..., nombre de runner mcps-hpc-01, label ubuntu-latest:host, corre jobs directo en el host sin contenedores) llevaba 7 tareas procesadas corriendo a mano como proceso del usuario gen, sin systemd, sin sobrevivir a un reinicio. De paso se encontró, sin corregir, que la cuenta ACME de Let's Encrypt de todo Caddy #2 usa como contacto angel.pacheco@mcps-epcm.org, correo restringido en un offboarding en curso — los avisos de renovación fallida ya no le llegan a nadie; pendiente cambiar el email global del bloque `{ email ... }` del Caddyfile.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Agregado el bloque de Caddy para clusterlog.mcps-epcm.org en /mnt/cephfs/hot/caddy/Caddyfile, mismo patrón DNS-01 vía Route53 que el resto de dominios
|
||||||
|
- Creado /etc/systemd/system/gitea-runner.service (User=gen, WorkingDirectory=/opt/gitea-runner, ExecStart=/opt/gitea-runner/act_runner daemon, Restart=always), habilitado; se terminó el proceso manual y se levantó por systemd conservando el mismo uuid/identidad de runner ya registrado, sin re-registrar nada
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
```console
|
||||||
|
caddy validate
|
||||||
|
caddy reload
|
||||||
|
systemctl enable --now gitea-runner.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- caddy validate limpio antes de hacer reload
|
||||||
|
- curl sirviendo 200 en la ruta del manual real de clusterlog.mcps-epcm.org tras el reload, sin downtime del resto de sitios detrás de Caddy
|
||||||
|
- Certificado Let's Encrypt emitido en ~30s tras el reload
|
||||||
|
- El runner levantado por systemd conservó el mismo uuid/identidad ya registrado en Gitea
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
Quitar el bloque agregado del Caddyfile y ejecutar caddy reload revierte clusterlog.mcps-epcm.org al estado anterior (dominio sin servir). Para el runner, systemctl stop gitea-runner.service y volver a correr ./act_runner daemon a mano restauraría el modo anterior, sin persistencia (no recomendado).
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
+++
|
||||||
|
title = "Reparar PG dañado (HEALTH_ERR) en el clúster Ceph de mcps-hpc-01"
|
||||||
|
description = "Diagnóstico y reparación de un PG_DAMAGED real (pg 3.ec) en el clúster cephadm de producción; 407 pgs sin deep-scrub a tiempo se documentan como trade-off, no como bug, y no se tocaron."
|
||||||
|
date = 2026-08-19T18:56:05Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260819-6ea868-reparar-pg-danado-health-err-en-el-cluster-ceph-de-mcps-hpc-01"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["almacenamiento", "ceph", "salud-cluster"]
|
||||||
|
systems = ["ceph", "mcps-hpc-01"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260819-6EA868"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "high"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El clúster Ceph de mcps-hpc-01 (cephadm, fsid 9dd39ee8-b987-11f0-9c83-3497f632acb0: 5 mons, mgr activo+standby, 2 MDS, 20 OSDs, 4 pools, 1089 pgs, ~45 TiB usados de 232 TiB) respalda cephfs_hpc, del que dependen Caddy #2, Zammad y en general /mnt/cephfs. Al revisar `ceph -s` salió HEALTH_ERR por dos causas distintas: 1 scrub error/PG_DAMAGED en pg 3.ec, y 407 pgs sin deep-scrub a tiempo (warning, no error). El repair de 3.ec (`ceph pg repair 3.ec`) quedó corriendo en segundo plano al cierre de la sesión — es un deep-scrub completo del PG (~42 GiB), tarda varios minutos; falta reconfirmar con `ceph -s` / `ceph health detail` que OSD_SCRUB_ERRORS y PG_DAMAGED ya no aparecen.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Diagnosticado pg 3.ec con rados list-inconsistent-obj: el shard primario (osd.10) está limpio (digest correcto) y el shard de la réplica (osd.13) tiene read_error puro, sin ambigüedad entre copias
|
||||||
|
- Confirmado con smartctl que el disco de osd.13 no tenía degradación física antes de reparar (PASSED, Reallocated_Sector_Ct=0, Current_Pending_Sector=0, Offline_Uncorrectable=0)
|
||||||
|
- Ejecutado ceph pg repair 3.ec para reescribir la copia buena sobre la réplica dañada
|
||||||
|
- No se modificó osd_max_scrubs ni ninguna configuración de deep-scrub: los 407 pgs atrasados (la config ya tiene osd_scrub_load_threshold=8.0 y osd_deep_scrub_interval=15 días, ambos más permisivos que el default) son un trade-off de rendimiento vs. velocidad de scrub que compite con I/O de clientes reales de cephfs en horario laboral, no un bug — no se cambió sin consultar al equipo
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
```console
|
||||||
|
ceph -s
|
||||||
|
rados list-inconsistent-obj 3.ec
|
||||||
|
smartctl -H /dev/sda
|
||||||
|
smartctl -A /dev/sda
|
||||||
|
ceph pg repair 3.ec
|
||||||
|
ceph osd dump | grep flag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- smartctl -H y -A en el disco de osd.13 confirmaron salud del disco antes de reparar (sin sectores reasignados ni pendientes)
|
||||||
|
- rados list-inconsistent-obj 3.ec confirmó que sólo existía una copia válida (osd.10), sin ambigüedad entre réplicas, antes de ejecutar el repair
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
El pg repair no tiene una reversión directa una vez completado; si el repair fallara o el objeto siguiera inconsistent, requeriría intervención manual adicional (revisar de nuevo con rados list-inconsistent-obj y, si aplica, restaurar desde otra réplica sana).
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
+++
|
||||||
|
title = "Limpiar backlog de ruido en tickets 'new' de Zammad"
|
||||||
|
description = "Depuración por SQL directo del backlog new del grupo de soporte MCPS: 329 tickets de rebotes/pruebas cerrados, 12 tickets reales conservados sin tocar."
|
||||||
|
date = 2026-08-19T18:56:01Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260819-727073-limpiar-backlog-de-ruido-en-tickets-new-de-zammad"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["limpieza", "soporte", "zammad"]
|
||||||
|
systems = ["zammad"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260819-727073"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "medium"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Tras aplicar la estrategia B de roles en Zammad, el grupo Equipo de soporte MCPS mostraba 341 tickets en estado new, nunca tocados por un agente. Se pidió revisar qué eran realmente antes de tocar nada. Agrupando por remitente salió que casi todo era ruido: 305 rebotes de mailer-daemon@mcps-epcm.org + 7 de mailer-daemon@amazonses.com, 14 pruebas de estudiomexicanodecohorte@gmail.com (cuenta admin huérfana), 3 pruebas de soporte@mcps-epcm.org, y 12 tickets reales de personas. De esos 12, los 3 más recientes (agosto 2026) parecen solicitudes activas de acceso/seguridad; el ticket 31658 ('ERROR de conexión', angel.pacheco@mcps-epcm.org, 2026-08-07) corresponde a la persona de un offboarding en curso y no debe cerrarse ni ignorarse sin que Fernando o Alexis lo revisen a mano.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Cerrados por SQL directo 329 tickets de ruido: 312 rebotes (mailer-daemon@mcps-epcm.org / mailer-daemon@amazonses.com, por remitente+estado) y 17 de prueba (estudiomexicanodecohorte@gmail.com y soporte@mcps-epcm.org, por número de ticket exacto)
|
||||||
|
- No se tocaron los 12 tickets reales; quedan como backlog real pendiente de respuesta humana del equipo de soporte
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
_No se registraron comandos._
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- Confirmado antes de cerrar que el trigger 'Ticket Cerrado' está inactivo (triggers.active = f) en Zammad
|
||||||
|
- Confirmado que un UPDATE SQL directo no pasa por el motor de triggers de Zammad (corre en la app Rails, no en la base de datos), por lo que cerrar sin agregar un artículo nuevo no dispara correo al cliente
|
||||||
|
- Estado del grupo verificado después de la limpieza: 608 closed / 23 open / 12 new (antes: 279 closed / 23 open / 341 new)
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
Los tickets cerrados se pueden reabrir manualmente en Zammad por número de ticket; no se modificó ni se borró ningún dato, solo el estado de los tickets de ruido.
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
+++
|
||||||
|
title = "Desplegar Uptime Kuma como monitoreo central (health.mcps-epcm.org)"
|
||||||
|
description = "Contenedor Uptime Kuma en mcps-hpc-01 con 22 monitores en 3 grupos; gotcha de systemd Type=notify que causaba reinicio en loop."
|
||||||
|
date = 2026-08-19T18:55:56Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260819-c255ff-desplegar-uptime-kuma-como-monitoreo-central-health-mcps-epcm-org"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["monitoreo", "systemd", "uptime-kuma"]
|
||||||
|
systems = ["mcps-hpc-01", "uptime-kuma"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260819-C255FF"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "low"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Se desplegó Uptime Kuma como monitoreo centralizado de los servicios web de mcps-epcm.org y del clúster HPC, expuesto en https://health.mcps-epcm.org (VPN-only). Quedan pendientes sin resolver, encontrados por el propio monitoreo al arrancar: pgscat.mcps-epcm.org responde 502 (backend no responde) y encuestas.mcps-epcm.org responde 404 en / (posiblemente normal si esa app solo sirve rutas específicas).
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Contenedor docker.io/louislam/uptime-kuma:1 desplegado en mcps-hpc-01, systemd unit container-uptime-kuma.service, IP fija 10.89.0.51 en mcps-net
|
||||||
|
- Corregido Type=notify a Type=simple en el systemd unit: Uptime Kuma no manda la señal sd_notify y con Type=notify se quedaba en 'activating' y reiniciaba en loop
|
||||||
|
- 22 monitores configurados en 3 grupos (servicios web mcps-epcm.org, infraestructura mcps-hpc-01, nodos del clúster HPC) mediante script Python con la librería no oficial uptime-kuma-api
|
||||||
|
- Se quitaron del monitoreo pgscat y mta-sts a pedido explícito; los servicios detrás de Caddy siguen existiendo, solo se sacaron del monitor
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
```console
|
||||||
|
pip install uptime-kuma-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- Dashboard accesible en https://health.mcps-epcm.org con certificado real vía DNS-01
|
||||||
|
- Página pública de estado sin login disponible en https://health.mcps-epcm.org/status/mcps
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
Detener y deshabilitar container-uptime-kuma.service; no afecta a los servicios monitoreados, solo deja de haber monitoreo centralizado.
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
+++
|
||||||
|
title = "Reparar 2 PGs inconsistentes nuevas en Ceph (HEALTH_ERR, mcps-hpc-01)"
|
||||||
|
description = "Diagnóstico y repair de pg 3.80 y pg 3.17b; confirmado que pg 3.ec (memoria del 2026-08-17) quedó sana. osd.13 tuvo un segundo read_error en pocos días; no se pudo revisar el disco de osd.11 por falta de acceso a MCPS-HPC05. El backlog de deep-scrub atrasado no se tocó."
|
||||||
|
date = 2026-08-20T07:05:46Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260820-4d10c2-reparar-2-pgs-inconsistentes-nuevas-en-ceph-health-err-mcps-hpc-01"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["almacenamiento", "ceph", "salud-cluster"]
|
||||||
|
systems = ["ceph", "mcps-hpc-01"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260820-4D10C2"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "high"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Se revisó el estado real de Ceph a pedido explícito ('corrige los problemas de salud de ceph'), sin asumir que seguía igual que en la memoria del 2026-08-17. La pg 3.ec de esa memoria ya no aparece como inconsistente: el repair anterior quedó resuelto. En su lugar, ceph -s mostró HEALTH_ERR por 2 PGs nuevas inconsistentes (3.80 y 3.17b) y 353 pgs sin deep-scrub a tiempo (bajó a 352 durante la sesión, mismo backlog documentado antes, no un problema nuevo). osd.13 (mcps-hpc-01, /dev/sda) es el mismo disco que tuvo el read_error del 17 de agosto — esta es la segunda vez en pocos días, aunque SMART lo sigue marcando PASSED sin sectores reasignados ni pendientes. No se pudo correr smartctl sobre el disco de osd.11 (/dev/sdb, host MCPS-HPC05): ni esta máquina ni mcps-hpc-01 tienen acceso SSH configurado a ese host; no se intentó adivinar credenciales. El repair de ambas PGs se instruyó igual con base en el diagnóstico de rados list-inconsistent-obj (copia primaria limpia, réplica con read_error puro, sin ambigüedad), que no depende de haber confirmado la salud física del disco de la réplica. El backlog de 352 pgs sin deep-scrub a tiempo sigue siendo el mismo trade-off rendimiento/velocidad de scrub documentado en la memoria anterior (osd_max_scrubs=1, compite con I/O de clientes reales) — no se tocó, sigue pendiente de decisión del equipo.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Confirmado que pg 3.ec (memoria del 2026-08-17) ya no aparece inconsistente: el repair anterior fue efectivo
|
||||||
|
- Diagnosticadas pg 3.80 (osd.8 primario limpio / osd.13 read_error) y pg 3.17b (osd.1 primario limpio / osd.11 read_error) con rados list-inconsistent-obj: en ambas, una sola copia válida, sin ambigüedad
|
||||||
|
- Confirmado con smartctl que el disco de osd.13 (/dev/sda, mcps-hpc-01) sigue PASSED, sin degradación física reportada, pese a ser el segundo read_error en esa unidad en pocos días
|
||||||
|
- Ejecutado ceph pg repair 3.80 y ceph pg repair 3.17b; ambas PGs entraron a estado active+clean+scrubbing+deep+inconsistent+repair
|
||||||
|
- No se modificó osd_max_scrubs ni ninguna configuración de deep-scrub: el backlog de 352 pgs atrasados se dejó igual, es la misma decisión de trade-off pendiente de la memoria anterior
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
```console
|
||||||
|
ceph -s
|
||||||
|
ceph health detail
|
||||||
|
rados list-inconsistent-obj 3.80 --format=json-pretty
|
||||||
|
rados list-inconsistent-obj 3.17b --format=json-pretty
|
||||||
|
ceph osd tree
|
||||||
|
ceph osd metadata 13
|
||||||
|
ceph osd metadata 11
|
||||||
|
smartctl -H /dev/sda
|
||||||
|
smartctl -A /dev/sda
|
||||||
|
ceph pg repair 3.80
|
||||||
|
ceph pg repair 3.17b
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- rados list-inconsistent-obj confirmó, para ambas PGs, que solo existía una copia válida (primario limpio) antes de reparar
|
||||||
|
- smartctl -H y -A en el disco de osd.13 confirmaron salud del disco (PASSED, sin sectores reasignados/pendientes) antes de reparar
|
||||||
|
- ceph -s tras el repair mostró ambas PGs en active+clean+scrubbing+deep+inconsistent+repair, confirmando que el repair quedó corriendo; no se esperó a que terminara en esta sesión
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
El pg repair no tiene reversión directa. Si al terminar el deep-scrub alguna de las dos PGs sigue inconsistent (ceph health detail), requiere diagnóstico manual adicional; si el patrón se repite en osd.13 una tercera vez, considerar programar el reemplazo preventivo del disco aunque SMART siga en PASSED.
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
+++
|
||||||
|
title = "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana"
|
||||||
|
description = "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets."
|
||||||
|
date = 2026-08-24T20:49:04Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["correo", "docker-mailserver", "grafana", "smtp", "zammad"]
|
||||||
|
systems = ["grafana", "mail-server", "mcps-hpc-01", "mcps-vpn-server", "zammad"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260824-254312"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "medium"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El usuario pidió configurar correo saliente en mail.mcps-epcm.org (10.8.0.1, mcps-vpn-server) para que soporte.mcps-epcm.org (Zammad) y grafana.mcps-epcm.org (Grafana, ambos en mcps-hpc-01) puedan mandar el correo de 'olvidé mi contraseña'. Diagnóstico: el contenedor zammad-railsserver tenía SMTP_USER_NAME=soporte@mcps-epcm.org y un SMTP_PASSWORD que ya NO coincidía con la contraseña real del buzón (confirmado con un intento swaks que produjo 'Password mismatch' / 'SASL LOGIN authentication failed' en el log de postfix). Ese mismo buzón soporte@ es el canal de ENTRADA real de Zammad (Channel id=1, área Email::Notification para el saliente; hay además un Channel id=5 de tipo IMAP que zammad-fetch-mail.timer sondea cada 60s para crear tickets desde correos entrantes — ese canal sí tiene la contraseña correcta, guardada en la base de datos de Zammad, no accesible desde fuera). Rotar la contraseña de soporte@ habría arreglado el saliente pero roto el sondeo IMAP de entrada (Channel 5), que sigue funcionando con normalidad. Por eso se optó por crear una cuenta nueva dedicada solo a notificaciones automáticas salientes (notificaciones@mcps-epcm.org), dejando soporte@ intacto. Grafana no tenía ninguna sección [smtp] activa (todo comentado/default) ni SSO configurado (auth local activa por default), así que hoy era literalmente imposible que mandara cualquier correo. Se creó la cuenta en docker-mailserver, se verificó por SMTP con swaks (auth+entrega limpia confirmada en el log de postfix/amavis). Se actualizó el Channel id=1 de Zammad (área Email::Notification) por rails runner dentro del contenedor zammad-railsserver, apuntando host/user/password/domain a la cuenta nueva, dejando notification_sender ('Soporte MCPS <soporte@mcps-epcm.org>') sin cambios para que el remitente visible siga siendo reconocible aunque la autenticación SMTP real sea con notificaciones@. Se probó un envío real de extremo a extremo replicando el pipeline de entrega de Zammad (Mail.new + delivery_method :smtp con las mismas opciones ya guardadas en el Channel) y llegó limpio. Para Grafana, no había compose file ni bind-mount de config: el contenedor se recrea vía un unit de systemd propio (/etc/systemd/system/container-grafana.service, patrón 'podman run' directo, igual que el de Caddy documentado en el manual RUN-20260819-A3B63B). Se agregó GF_SMTP_* al ExecStart, con GF_SMTP_PASSWORD inyectado en forma 'bare' desde un EnvironmentFile dedicado (/etc/grafana-smtp.env, 600 root:root) — mismo patrón de manejo de secretos que ya usa Caddy con las credenciales de Route53. Se respaldó la unit original antes de editar (container-grafana.service.bak-2026-08-24). Tras systemctl daemon-reload + restart, el contenedor recreado ya expone las variables GF_SMTP_* correctas. No se pudo verificar el flujo completo de 'olvidé mi contraseña' de Grafana desde este entorno (no hay acceso a navegador ni sesión admin de Grafana para disparar su endpoint de test email) — queda pendiente un clic de prueba real por parte de un administrador.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Creada la cuenta notificaciones@mcps-epcm.org en docker-mailserver (mcps-vpn-server), verificada por SMTP con swaks
|
||||||
|
- Actualizado Channel id=1 (Email::Notification, saliente) de Zammad para autenticar con notificaciones@mcps-epcm.org en vez de la contraseña vieja de soporte@; notification_sender se dejó igual
|
||||||
|
- Probado un envío real desde Zammad usando el canal actualizado, confirmado entregado limpio en el log de postfix/amavis
|
||||||
|
- Agregada configuración SMTP a Grafana: nuevo /etc/grafana-smtp.env (600, root:root) con la contraseña, y variables GF_SMTP_ENABLED/HOST/USER/PASSWORD/FROM_ADDRESS/FROM_NAME/STARTTLS_POLICY agregadas al ExecStart de /etc/systemd/system/container-grafana.service (unit original respaldada como .bak-2026-08-24)
|
||||||
|
- systemctl daemon-reload + restart de container-grafana.service; confirmado que el contenedor recreado expone las variables GF_SMTP_* nuevas
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
```console
|
||||||
|
podman exec mailserver setup email list
|
||||||
|
podman exec mailserver setup email add notificaciones@mcps-epcm.org <password redactado>
|
||||||
|
swaks --to prueba@mcps-epcm.org --from notificaciones@mcps-epcm.org --server mail.mcps-epcm.org:587 --auth LOGIN --auth-user notificaciones@mcps-epcm.org --auth-password <redactado> --tls --hide-all
|
||||||
|
podman exec zammad-railsserver bash -lc "rails r 'Channel.where(area: %q(Email::Notification))...'"
|
||||||
|
podman exec zammad-railsserver bash -lc "rails r 'c = Channel.find(1); ... c.options[:outbound][:options][:password] = <redactado>; c.save!'"
|
||||||
|
podman exec zammad-railsserver bash -lc "rails r 'Mail.new(...).delivery_method(:smtp, ...).deliver'" # prueba end-to-end
|
||||||
|
systemctl cat container-grafana.service
|
||||||
|
cp /etc/systemd/system/container-grafana.service /etc/systemd/system/container-grafana.service.bak-2026-08-24
|
||||||
|
tee /etc/grafana-smtp.env # GF_SMTP_PASSWORD=<redactado>, chmod 600
|
||||||
|
systemctl daemon-reload && systemctl restart container-grafana.service
|
||||||
|
podman inspect grafana --format '{{range .Config.Env}}{{println .}}{{end}}'
|
||||||
|
```
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- swaks contra la cuenta notificaciones@ terminó con exit 0 y el log de postfix mostró sasl_username=notificaciones@mcps-epcm.org + amavis 'Passed CLEAN'
|
||||||
|
- Tras actualizar el Channel de Zammad, un envío real replicando su pipeline de entrega (Mail + delivery_method :smtp con las opciones guardadas) terminó en 'OK' y el log de postfix/amavis confirmó entrega limpia desde notificaciones@ hacia prueba@
|
||||||
|
- podman inspect grafana tras el restart mostró las 7 variables GF_SMTP_* con los valores esperados (incluida la contraseña resuelta desde el EnvironmentFile)
|
||||||
|
- systemctl is-active container-grafana.service devolvió 'active' tras el restart, sin reinicios en bucle
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
Restaurar /etc/systemd/system/container-grafana.service.bak-2026-08-24 sobre container-grafana.service, borrar /etc/grafana-smtp.env, systemctl daemon-reload && systemctl restart container-grafana.service para volver Grafana a su estado sin SMTP. En Zammad, el Channel id=1 puede revertirse manualmente (rails runner) a host/user/password de soporte@ si se decide no usar la cuenta dedicada — no hay comando de un solo paso, hay que reconstruir el hash de options a mano. La cuenta notificaciones@mcps-epcm.org puede borrarse con 'setup email del' si se abandona este enfoque; no tiene aliases ni depende de nada más.
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
+++
|
||||||
|
title = "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph"
|
||||||
|
description = "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad."
|
||||||
|
date = 2026-08-24T20:49:00Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["almacenamiento", "ceph", "mon", "salud-cluster"]
|
||||||
|
systems = ["ceph", "mcps-hpc-01"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260824-C5E50A"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "high"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
A pedido del usuario ('ayúdame a resolver los problemas de ceph en el login node') se revisó ceph -s en mcps-hpc-01 y salió HEALTH_WARN con CEPHADM_FAILED_DAEMON + MON_DOWN (mon.mcps-hpc-01 fuera de quorum) + RECENT_CRASH (miles de crashes acumulados) + el backlog de deep-scrub ya conocido. journalctl mostró que ceph-mon abortaba en cada arranque con ceph_assert(r >= 0) en MonitorDBStore::clear() durante Monitor::preinit() — corrupción del store local, no disco lleno (367G libres de 723G). Los otros 4 mons (MCPS-HPC02/03/04/05) estaban sanos y en quorum, así que no había riesgo real de perder el cluster: la remediación estándar de Ceph para este caso (mon con store corrupto, quorum sano en el resto) es sacarlo del monmap y dejar que cephadm lo redespliegue desde cero, sincronizando vía Paxos desde los mons sanos. Se ejecutó así, con autorización explícita del usuario para cada comando (el clasificador de auto mode de Claude Code bloqueó cada systemctl/ceph mutante uno por uno; algunos los corrió el propio usuario). Aparte de eso, ceph -s mostró HEALTH_ERR por 2 PGs nuevas inconsistentes (3.29, 3.65) — mismo patrón que memorias anteriores (MEM-20260819-6EA868, MEM-20260820-4D10C2): rados list-inconsistent-obj confirmó en ambas una sola copia dañada (osd.13 con read_error en 3.29, osd.1 con data_digest_mismatch en 3.65) contra una copia primaria limpia, sin ambigüedad. osd.13 (mcps-hpc-01, /dev/sda) tuvo su TERCER read_error en pocos días — SMART sigue en PASSED sin sectores reasignados/pendientes, pero la recurrencia ya amerita programar reemplazo preventivo, no solo observar. No se pudo revisar el disco de osd.1 (host MCPS-HPC02): no hay acceso SSH configurado desde este entorno ni desde mcps-hpc-01 hacia ese host; no se intentó adivinar credenciales, mismo criterio que con MCPS-HPC05 en la memoria anterior. El repair de ambas PGs tardó ~1h40 en completar (deep-scrub de PGs grandes de este pool), confirmado por ceph -s volviendo a HEALTH_WARN limpio (solo el backlog de deep-scrub). Se archivaron con ceph crash archive-all los ~9660 reportes de crash acumulados durante el incidente del mon, todos con timestamp del 2026-08-21 (no eran crashes nuevos). El backlog de deep-scrub (313 pgs) se dejó igual, mismo trade-off pendiente de decisión de equipo documentado en memorias anteriores.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Detenido el daemon mon.mcps-hpc-01 en crash-loop (systemctl stop)
|
||||||
|
- Sacado mon.mcps-hpc-01 del monmap (ceph mon remove mcps-hpc-01)
|
||||||
|
- Eliminado el daemon corrupto vía cephadm (ceph orch daemon rm mon.mcps-hpc-01 --force); cephadm lo redesplegó automáticamente con store fresco y volvió a quorum (5/5)
|
||||||
|
- Diagnosticadas y reparadas pg 3.29 (osd.10 primario limpio / osd.13 read_error) y pg 3.65 (osd.7 primario limpio / osd.1 data_digest_mismatch) con ceph pg repair
|
||||||
|
- Archivados ~9660 crash reports viejos (todos del 2026-08-21, del mismo incidente del mon) con ceph crash archive-all
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
```console
|
||||||
|
ceph -s
|
||||||
|
ceph health detail
|
||||||
|
ceph crash ls / ceph crash info <id>
|
||||||
|
ceph orch ps --daemon_type mon
|
||||||
|
systemctl status ceph-<fsid>@mon.mcps-hpc-01.service
|
||||||
|
journalctl -u ceph-<fsid>@mon.mcps-hpc-01.service
|
||||||
|
du -sh /var/lib/ceph/<fsid>/mon.mcps-hpc-01
|
||||||
|
systemctl stop ceph-<fsid>@mon.mcps-hpc-01.service
|
||||||
|
ceph mon remove mcps-hpc-01
|
||||||
|
ceph mon stat / ceph quorum_status
|
||||||
|
ceph orch daemon rm mon.mcps-hpc-01 --force
|
||||||
|
rados list-inconsistent-obj 3.29 --format=json-pretty
|
||||||
|
rados list-inconsistent-obj 3.65 --format=json-pretty
|
||||||
|
ceph osd metadata 13 / 1 / 10 / 7
|
||||||
|
smartctl -H /dev/sda; smartctl -A /dev/sda
|
||||||
|
ceph pg repair 3.29
|
||||||
|
ceph pg repair 3.65
|
||||||
|
ceph crash archive-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- journalctl del daemon mostró backtrace consistente en cada crash: ceph_assert(r >= 0) en MonitorDBStore::clear(), confirmando corrupción del store y no un problema transitorio
|
||||||
|
- ceph quorum_status antes de tocar nada confirmó 4/5 mons sanos en quorum, sin riesgo de perder el cluster al remover el mon roto
|
||||||
|
- tras el redeploy, ceph -s mostró mon: 5 daemons, quorum ...,mcps-hpc-01 y desaparecieron CEPHADM_FAILED_DAEMON y MON_DOWN
|
||||||
|
- rados list-inconsistent-obj confirmó, para ambas PGs, que solo existía una copia dañada antes de reparar (sin ambigüedad de cuál copia era la buena)
|
||||||
|
- smartctl -H/-A en el disco de osd.13 confirmó PASSED sin sectores reasignados/pendientes antes de reparar
|
||||||
|
- ceph -s tras ~1h40 mostró HEALTH_WARN limpio (solo PG_NOT_DEEP_SCRUBBED), sin OSD_SCRUB_ERRORS ni PG_DAMAGED
|
||||||
|
- ceph health detail tras archivar confirmó 0 crashes nuevos (RECENT_CRASH ya no aparece)
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
El redeploy del mon y el pg repair no tienen reversión directa; el estado post-cambio es el estado sano verificado (5/5 mons, HEALTH_WARN limpio). Si mon.mcps-hpc-01 vuelve a fallar en preinit tras este redeploy, ya no es un problema de store corrupto (se recreó desde cero) — investigar disco/hardware del host. Si el patrón de read_error en osd.13 se repite una CUARTA vez, ya no tratarlo como watch-item pasivo: programar reemplazo preventivo del disco aunque SMART siga en PASSED.
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
+++
|
||||||
|
title = "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)"
|
||||||
|
description = "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí)."
|
||||||
|
date = 2026-08-25T01:01:15Z
|
||||||
|
authors = ["gen"]
|
||||||
|
slug = "mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea"
|
||||||
|
template = "memory.html"
|
||||||
|
|
||||||
|
[taxonomies]
|
||||||
|
categories = ["memorias"]
|
||||||
|
tags = ["correo", "docker-mailserver", "gitea", "smtp"]
|
||||||
|
systems = ["gitea", "mail-server", "mcps-hpc-01", "mcps-vpn-server"]
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
id = "MEM-20260825-EED6F1"
|
||||||
|
kind = "memory"
|
||||||
|
author_id = "gen"
|
||||||
|
author_fingerprint = "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg"
|
||||||
|
risk = "medium"
|
||||||
|
ticket = ""
|
||||||
|
review_required = true
|
||||||
|
review_policy = "all-active"
|
||||||
|
reviewer_snapshot = true
|
||||||
|
reviewers = ["gen"]
|
||||||
|
started_at = ""
|
||||||
|
finished_at = ""
|
||||||
|
duration_minutes = 0
|
||||||
|
agent = "claude-code"
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El usuario pidió extender el correo saliente ya configurado para Zammad y Grafana (ver MEM-20260824-254312) también a git.mcps-epcm.org (Gitea 1.27.1, contenedor 'gitea' en mcps-hpc-01). Revisando /opt/gitea/data/gitea/conf/app.ini no había sección [mailer] en absoluto — mismo escenario que Grafana: Gitea no podía mandar ningún correo, incluyendo el de recuperación de contraseña. El contenedor ya se gestiona con un unit de systemd propio (/etc/systemd/system/container-gitea.service) que arma la config vía variables GITEA__<seccion>__<clave> (convención propia de Gitea, ya usada para server/database/service/security en ese mismo unit) — se siguió el mismo patrón agregando GITEA__mailer__*, en vez de editar app.ini a mano, porque el entrypoint de Gitea vuelve a sincronizar esas variables a app.ini en cada arranque del contenedor (editar el archivo directamente se habría perdido o desincronizado en el próximo restart). La contraseña de notificaciones@ se inyectó igual que en Grafana: forma 'bare' de podman vía un EnvironmentFile dedicado (/etc/gitea-smtp.env, 600 root:root), no hardcodeada en el unit. Se respaldó el unit original antes de editar. Tras systemctl daemon-reload + restart, se confirmó que app.ini quedó con [mailer] ENABLED=true y los valores correctos, el contenedor arrancó limpio (sin errores de mail en el log) y el sitio respondió HTTP 200. No se disparó un envío real de prueba (a diferencia de Zammad): Gitea no expone un comando de CLI para probar el mailer, y el botón de 'enviar correo de prueba' vive en su panel de administración web, al que no tengo acceso desde este entorno. La credencial notificaciones@mcps-epcm.org ya se había verificado funcional por SMTP en la memoria anterior (swaks + entrega real confirmada desde Zammad), así que la confianza en que funcione aquí es alta pero no está confirmada específicamente para Gitea.
|
||||||
|
|
||||||
|
## Cambios realizados
|
||||||
|
|
||||||
|
- Creado /etc/gitea-smtp.env (600, root:root) con GITEA__mailer__PASSWD
|
||||||
|
- Respaldado el unit original como container-gitea.service.bak-2026-08-24
|
||||||
|
- Agregadas variables GITEA__mailer__ENABLED/PROTOCOL/SMTP_ADDR/SMTP_PORT/FROM/USER/PASSWD al ExecStart de /etc/systemd/system/container-gitea.service, con EnvironmentFile=/etc/gitea-smtp.env
|
||||||
|
- systemctl daemon-reload + restart de container-gitea.service
|
||||||
|
|
||||||
|
## Comandos relevantes (secretos redactados)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
```console
|
||||||
|
podman inspect gitea --format '{{range .Config.Env}}{{println .}}{{end}}'
|
||||||
|
cat /opt/gitea/data/gitea/conf/app.ini
|
||||||
|
podman exec gitea gitea --version
|
||||||
|
systemctl cat container-gitea.service
|
||||||
|
cp /etc/systemd/system/container-gitea.service /etc/systemd/system/container-gitea.service.bak-2026-08-24
|
||||||
|
tee /etc/gitea-smtp.env # GITEA__mailer__PASSWD=<redactado>, chmod 600
|
||||||
|
systemctl daemon-reload && systemctl restart container-gitea.service
|
||||||
|
cat /opt/gitea/data/gitea/conf/app.ini # confirmar seccion [mailer]
|
||||||
|
podman logs gitea | grep -iE 'mail|error|fatal|panic'
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' http://10.89.0.60:3000/
|
||||||
|
```
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- app.ini tras el restart mostró [mailer] con ENABLED=true, PROTOCOL=smtp+starttls, SMTP_ADDR=mail.mcps-epcm.org, SMTP_PORT=587, USER/FROM=notificaciones@mcps-epcm.org y la contraseña correcta
|
||||||
|
- podman logs gitea sin líneas de error/fatal/panic ni menciones de mail tras el arranque
|
||||||
|
- curl al puerto interno del contenedor devolvió HTTP 200
|
||||||
|
- systemctl is-active container-gitea.service devolvió 'active', sin reinicios en bucle
|
||||||
|
|
||||||
|
## Riesgo y reversión
|
||||||
|
|
||||||
|
Restaurar /etc/systemd/system/container-gitea.service.bak-2026-08-24 sobre container-gitea.service, borrar /etc/gitea-smtp.env, systemctl daemon-reload && systemctl restart container-gitea.service para volver Gitea a su estado sin [mailer]. Pendiente: un administrador debe probar el flujo real de 'olvidé mi contraseña' en git.mcps-epcm.org (o el botón de correo de prueba en Admin > Configuración) para confirmar la entrega end-to-end, igual que sigue pendiente para Grafana.
|
||||||
@@ -1,15 +1,435 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"generated_at": "2026-08-13T06:42:03Z",
|
"generated_at": "2026-08-27T07:20:28Z",
|
||||||
"source_sha256": "64a404e7ccb669ab6712d92141f5e2446eb3256723075a42bd7037380f285050",
|
"source_sha256": "3b845d6aef09eadc3c336ab6c4bb170e461c213ddb59920be6f39ae46b37b0f9",
|
||||||
"stats": {
|
"stats": {
|
||||||
"active_admins": 1,
|
"active_admins": 1,
|
||||||
"pending_reviews": 0,
|
"pending_reviews": 7,
|
||||||
"open_tasks": 1,
|
"open_tasks": 1,
|
||||||
"active_projects": 1
|
"active_projects": 1
|
||||||
},
|
},
|
||||||
"review_queue": [],
|
"review_queue": [
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260825-EED6F1",
|
||||||
|
"title": "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)",
|
||||||
|
"description": "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí).",
|
||||||
|
"date": "2026-08-25T01:01:15Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "medium",
|
||||||
|
"tags": [
|
||||||
|
"correo",
|
||||||
|
"docker-mailserver",
|
||||||
|
"gitea",
|
||||||
|
"smtp"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"gitea",
|
||||||
|
"mail-server",
|
||||||
|
"mcps-hpc-01",
|
||||||
|
"mcps-vpn-server"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea/",
|
||||||
|
"content_sha256": "69f1610c2d9004fd7a0948af970a12c98178d701231461e2d18c2219c9cb13aa",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260824-254312",
|
||||||
|
"title": "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana",
|
||||||
|
"description": "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets.",
|
||||||
|
"date": "2026-08-24T20:49:04Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "medium",
|
||||||
|
"tags": [
|
||||||
|
"correo",
|
||||||
|
"docker-mailserver",
|
||||||
|
"grafana",
|
||||||
|
"smtp",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"grafana",
|
||||||
|
"mail-server",
|
||||||
|
"mcps-hpc-01",
|
||||||
|
"mcps-vpn-server",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana/",
|
||||||
|
"content_sha256": "afdbe017e912bc7c5926ad5a66b9e79e055099c1eb564f72d6d6258698d5fcc9",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260824-C5E50A",
|
||||||
|
"title": "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph",
|
||||||
|
"description": "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad.",
|
||||||
|
"date": "2026-08-24T20:49:00Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "high",
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"mon",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph/",
|
||||||
|
"content_sha256": "e3ca2349c7ebbfc769db94409776e82677c818d10de0124881a2b1d1f4f96233",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260820-4D10C2",
|
||||||
|
"title": "Reparar 2 PGs inconsistentes nuevas en Ceph (HEALTH_ERR, mcps-hpc-01)",
|
||||||
|
"description": "Diagnóstico y repair de pg 3.80 y pg 3.17b; confirmado que pg 3.ec (memoria del 2026-08-17) quedó sana. osd.13 tuvo un segundo read_error en pocos días; no se pudo revisar el disco de osd.11 por falta de acceso a MCPS-HPC05. El backlog de deep-scrub atrasado no se tocó.",
|
||||||
|
"date": "2026-08-20T07:05:46Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "high",
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260820-4d10c2-reparar-2-pgs-inconsistentes-nuevas-en-ceph-health-err-mcps-hpc-01/",
|
||||||
|
"content_sha256": "6e6e4529c8e0a9cb8447748ba89da1bafe3383f5e192f18f927b3ee546d0efb7",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-51DEA9",
|
||||||
|
"title": "Corregir despliegue de clusterlog en Caddy y dar persistencia al runner de Gitea Actions",
|
||||||
|
"description": "Bloque de Caddy faltante para clusterlog.mcps-epcm.org agregado; runner nativo de Gitea Actions pasado de proceso manual a systemd.",
|
||||||
|
"date": "2026-08-19T18:56:10Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "medium",
|
||||||
|
"tags": [
|
||||||
|
"caddy",
|
||||||
|
"clusterlog",
|
||||||
|
"gitea-actions",
|
||||||
|
"systemd"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"caddy",
|
||||||
|
"gitea-runner",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260819-51dea9-corregir-despliegue-de-clusterlog-en-caddy-y-dar-persistencia-al-runner-de-gitea-actions/",
|
||||||
|
"content_sha256": "21e603dbed0706b5438365fae7ede5f84302a12ac1360edc3a417f2bef51eff6",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-6EA868",
|
||||||
|
"title": "Reparar PG dañado (HEALTH_ERR) en el clúster Ceph de mcps-hpc-01",
|
||||||
|
"description": "Diagnóstico y reparación de un PG_DAMAGED real (pg 3.ec) en el clúster cephadm de producción; 407 pgs sin deep-scrub a tiempo se documentan como trade-off, no como bug, y no se tocaron.",
|
||||||
|
"date": "2026-08-19T18:56:05Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "high",
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260819-6ea868-reparar-pg-danado-health-err-en-el-cluster-ceph-de-mcps-hpc-01/",
|
||||||
|
"content_sha256": "ec3bf36fe97a386b876ecd332b26ce55dae983e7e0989a8ff154d1e82243b5d5",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-727073",
|
||||||
|
"title": "Limpiar backlog de ruido en tickets 'new' de Zammad",
|
||||||
|
"description": "Depuración por SQL directo del backlog new del grupo de soporte MCPS: 329 tickets de rebotes/pruebas cerrados, 12 tickets reales conservados sin tocar.",
|
||||||
|
"date": "2026-08-19T18:56:01Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"risk": "medium",
|
||||||
|
"tags": [
|
||||||
|
"limpieza",
|
||||||
|
"soporte",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"permalink": "/memorias/mem-20260819-727073-limpiar-backlog-de-ruido-en-tickets-new-de-zammad/",
|
||||||
|
"content_sha256": "642a33d4d5af8f99821ee817b4007ad11aec3b29767647cb1f5ec9d3788791ae",
|
||||||
|
"reviewed_by": [],
|
||||||
|
"missing_reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"required_count": 1,
|
||||||
|
"reviewed_count": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
"recent_memories": [
|
"recent_memories": [
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260825-EED6F1",
|
||||||
|
"title": "Extender el correo saliente de notificaciones a git.mcps-epcm.org (Gitea)",
|
||||||
|
"description": "Continuación de MEM-20260824-254312: se agregó la misma cuenta dedicada notificaciones@mcps-epcm.org como remitente SMTP de Gitea, que no tenía sección [mailer] configurada en absoluto (recuperación de contraseña también era imposible ahí).",
|
||||||
|
"date": "2026-08-25T01:01:15Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "medium",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"correo",
|
||||||
|
"docker-mailserver",
|
||||||
|
"gitea",
|
||||||
|
"smtp"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"gitea",
|
||||||
|
"mail-server",
|
||||||
|
"mcps-hpc-01",
|
||||||
|
"mcps-vpn-server"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea",
|
||||||
|
"permalink": "/memorias/mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea/",
|
||||||
|
"source_path": "content/memorias/2026-08-25-mem-20260825-eed6f1-extender-el-correo-saliente-de-notificaciones-a-git-mcps-epcm-org-gitea.md",
|
||||||
|
"content_sha256": "69f1610c2d9004fd7a0948af970a12c98178d701231461e2d18c2219c9cb13aa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260824-254312",
|
||||||
|
"title": "Configurar correo saliente para recuperación de contraseña en Zammad y Grafana",
|
||||||
|
"description": "Zammad tenía la credencial SMTP de salida desincronizada del buzón real (los resets de contraseña fallaban silenciosamente); Grafana no tenía SMTP configurado en absoluto. Se creó una cuenta dedicada de solo-salida y se configuró en ambos servicios, sin tocar el buzón soporte@ que Zammad usa para recibir tickets.",
|
||||||
|
"date": "2026-08-24T20:49:04Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "medium",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"correo",
|
||||||
|
"docker-mailserver",
|
||||||
|
"grafana",
|
||||||
|
"smtp",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"grafana",
|
||||||
|
"mail-server",
|
||||||
|
"mcps-hpc-01",
|
||||||
|
"mcps-vpn-server",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana",
|
||||||
|
"permalink": "/memorias/mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana/",
|
||||||
|
"source_path": "content/memorias/2026-08-24-mem-20260824-254312-configurar-correo-saliente-para-recuperacion-de-contrasena-en-zammad-y-grafana.md",
|
||||||
|
"content_sha256": "afdbe017e912bc7c5926ad5a66b9e79e055099c1eb564f72d6d6258698d5fcc9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260824-C5E50A",
|
||||||
|
"title": "Reparar mon.mcps-hpc-01 en crash-loop y 2 PGs inconsistentes nuevas en Ceph",
|
||||||
|
"description": "El mon del login node (mcps-hpc-01) llevaba ~16h en crash-loop por corrupción de su store RocksDB local; se sacó del monmap y cephadm lo redesplegó con store fresco. En paralelo aparecieron 2 PGs nuevas inconsistentes (HEALTH_ERR), diagnosticadas y reparadas sin ambigüedad.",
|
||||||
|
"date": "2026-08-24T20:49:00Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "high",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"mon",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph",
|
||||||
|
"permalink": "/memorias/mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph/",
|
||||||
|
"source_path": "content/memorias/2026-08-24-mem-20260824-c5e50a-reparar-mon-mcps-hpc-01-en-crash-loop-y-2-pgs-inconsistentes-nuevas-en-ceph.md",
|
||||||
|
"content_sha256": "e3ca2349c7ebbfc769db94409776e82677c818d10de0124881a2b1d1f4f96233"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260820-4D10C2",
|
||||||
|
"title": "Reparar 2 PGs inconsistentes nuevas en Ceph (HEALTH_ERR, mcps-hpc-01)",
|
||||||
|
"description": "Diagnóstico y repair de pg 3.80 y pg 3.17b; confirmado que pg 3.ec (memoria del 2026-08-17) quedó sana. osd.13 tuvo un segundo read_error en pocos días; no se pudo revisar el disco de osd.11 por falta de acceso a MCPS-HPC05. El backlog de deep-scrub atrasado no se tocó.",
|
||||||
|
"date": "2026-08-20T07:05:46Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "high",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260820-4d10c2-reparar-2-pgs-inconsistentes-nuevas-en-ceph-health-err-mcps-hpc-01",
|
||||||
|
"permalink": "/memorias/mem-20260820-4d10c2-reparar-2-pgs-inconsistentes-nuevas-en-ceph-health-err-mcps-hpc-01/",
|
||||||
|
"source_path": "content/memorias/2026-08-20-mem-20260820-4d10c2-reparar-2-pgs-inconsistentes-nuevas-en-ceph-health-err-mcps-hpc-01.md",
|
||||||
|
"content_sha256": "6e6e4529c8e0a9cb8447748ba89da1bafe3383f5e192f18f927b3ee546d0efb7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-51DEA9",
|
||||||
|
"title": "Corregir despliegue de clusterlog en Caddy y dar persistencia al runner de Gitea Actions",
|
||||||
|
"description": "Bloque de Caddy faltante para clusterlog.mcps-epcm.org agregado; runner nativo de Gitea Actions pasado de proceso manual a systemd.",
|
||||||
|
"date": "2026-08-19T18:56:10Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "medium",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"caddy",
|
||||||
|
"clusterlog",
|
||||||
|
"gitea-actions",
|
||||||
|
"systemd"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"caddy",
|
||||||
|
"gitea-runner",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260819-51dea9-corregir-despliegue-de-clusterlog-en-caddy-y-dar-persistencia-al-runner-de-gitea-actions",
|
||||||
|
"permalink": "/memorias/mem-20260819-51dea9-corregir-despliegue-de-clusterlog-en-caddy-y-dar-persistencia-al-runner-de-gitea-actions/",
|
||||||
|
"source_path": "content/memorias/2026-08-19-mem-20260819-51dea9-corregir-despliegue-de-clusterlog-en-caddy-y-dar-persistencia-al-runner-de-gitea-actions.md",
|
||||||
|
"content_sha256": "21e603dbed0706b5438365fae7ede5f84302a12ac1360edc3a417f2bef51eff6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-6EA868",
|
||||||
|
"title": "Reparar PG dañado (HEALTH_ERR) en el clúster Ceph de mcps-hpc-01",
|
||||||
|
"description": "Diagnóstico y reparación de un PG_DAMAGED real (pg 3.ec) en el clúster cephadm de producción; 407 pgs sin deep-scrub a tiempo se documentan como trade-off, no como bug, y no se tocaron.",
|
||||||
|
"date": "2026-08-19T18:56:05Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "high",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"almacenamiento",
|
||||||
|
"ceph",
|
||||||
|
"salud-cluster"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"ceph",
|
||||||
|
"mcps-hpc-01"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260819-6ea868-reparar-pg-danado-health-err-en-el-cluster-ceph-de-mcps-hpc-01",
|
||||||
|
"permalink": "/memorias/mem-20260819-6ea868-reparar-pg-danado-health-err-en-el-cluster-ceph-de-mcps-hpc-01/",
|
||||||
|
"source_path": "content/memorias/2026-08-19-mem-20260819-6ea868-reparar-pg-danado-health-err-en-el-cluster-ceph-de-mcps-hpc-01.md",
|
||||||
|
"content_sha256": "ec3bf36fe97a386b876ecd332b26ce55dae983e7e0989a8ff154d1e82243b5d5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-727073",
|
||||||
|
"title": "Limpiar backlog de ruido en tickets 'new' de Zammad",
|
||||||
|
"description": "Depuración por SQL directo del backlog new del grupo de soporte MCPS: 329 tickets de rebotes/pruebas cerrados, 12 tickets reales conservados sin tocar.",
|
||||||
|
"date": "2026-08-19T18:56:01Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "medium",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"limpieza",
|
||||||
|
"soporte",
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"zammad"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260819-727073-limpiar-backlog-de-ruido-en-tickets-new-de-zammad",
|
||||||
|
"permalink": "/memorias/mem-20260819-727073-limpiar-backlog-de-ruido-en-tickets-new-de-zammad/",
|
||||||
|
"source_path": "content/memorias/2026-08-19-mem-20260819-727073-limpiar-backlog-de-ruido-en-tickets-new-de-zammad.md",
|
||||||
|
"content_sha256": "642a33d4d5af8f99821ee817b4007ad11aec3b29767647cb1f5ec9d3788791ae"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_id": "MEM-20260819-C255FF",
|
||||||
|
"title": "Desplegar Uptime Kuma como monitoreo central (health.mcps-epcm.org)",
|
||||||
|
"description": "Contenedor Uptime Kuma en mcps-hpc-01 con 22 monitores en 3 grupos; gotcha de systemd Type=notify que causaba reinicio en loop.",
|
||||||
|
"date": "2026-08-19T18:55:56Z",
|
||||||
|
"author_id": "gen",
|
||||||
|
"author_fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"risk": "low",
|
||||||
|
"review_required": true,
|
||||||
|
"review_policy": "all-active",
|
||||||
|
"reviewer_snapshot": true,
|
||||||
|
"reviewers": [
|
||||||
|
"gen"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"monitoreo",
|
||||||
|
"systemd",
|
||||||
|
"uptime-kuma"
|
||||||
|
],
|
||||||
|
"systems": [
|
||||||
|
"mcps-hpc-01",
|
||||||
|
"uptime-kuma"
|
||||||
|
],
|
||||||
|
"slug": "mem-20260819-c255ff-desplegar-uptime-kuma-como-monitoreo-central-health-mcps-epcm-org",
|
||||||
|
"permalink": "/memorias/mem-20260819-c255ff-desplegar-uptime-kuma-como-monitoreo-central-health-mcps-epcm-org/",
|
||||||
|
"source_path": "content/memorias/2026-08-19-mem-20260819-c255ff-desplegar-uptime-kuma-como-monitoreo-central-health-mcps-epcm-org.md",
|
||||||
|
"content_sha256": "a74e4f1493843970c55c3197fb2e5266c8b04cf631e7fd187890051a0f076686"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"entry_id": "MEM-DEMO-001",
|
"entry_id": "MEM-DEMO-001",
|
||||||
"title": "Inicializar la estructura de la bitácora",
|
"title": "Inicializar la estructura de la bitácora",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "REV-20260824-9B2DA2",
|
||||||
|
"entry_id": "MEM-20260819-C255FF",
|
||||||
|
"admin_id": "gen",
|
||||||
|
"fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
|
||||||
|
"reviewed_at": "2026-08-24T18:25:00.241293734Z",
|
||||||
|
"content_sha256": "a74e4f1493843970c55c3197fb2e5266c8b04cf631e7fd187890051a0f076686"
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Reunión semanal de revisión operativa
|
# Reunión semanal de revisión operativa
|
||||||
|
|
||||||
|
> Versión ejecutable de los comandos de esta página:
|
||||||
|
> [`runbooks/weekly-review.md`](../runbooks/weekly-review.md) (abrible como
|
||||||
|
> notebook con Runme, o copiable a mano). Este documento conserva la agenda
|
||||||
|
> y el criterio; los bloques de comando canónicos viven sólo ahí.
|
||||||
|
|
||||||
## Objetivo
|
## Objetivo
|
||||||
|
|
||||||
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
|
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# ADR-0001: Git como fuente y Zola como proyección
|
|
||||||
|
|
||||||
- **Estado:** aceptada
|
|
||||||
- **Fecha:** 2026-08-12
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
El sitio debe mostrar estado compartido —revisiones, tiempo de tareas y estados— aunque Zola genere archivos estáticos. Guardar ese estado en el navegador no sería compartido ni auditable. Introducir una base de datos en el MVP aumentaría operación, permisos, respaldo y superficie de ataque.
|
|
||||||
|
|
||||||
## Decisión
|
|
||||||
|
|
||||||
La fuente de verdad será:
|
|
||||||
|
|
||||||
- Markdown para entidades legibles;
|
|
||||||
- un archivo JSON inmutable por evento;
|
|
||||||
- Git para historia y concurrencia;
|
|
||||||
- `clusterlog sync` para producir `data/generated/site_state.json`;
|
|
||||||
- Zola para consumir la proyección en modo sólo lectura.
|
|
||||||
|
|
||||||
La proyección es desechable y nunca se edita a mano.
|
|
||||||
|
|
||||||
## Consecuencias positivas
|
|
||||||
|
|
||||||
- El repositorio funciona sin un servicio permanente.
|
|
||||||
- Toda la historia es inspeccionable con herramientas estándar.
|
|
||||||
- Los conflictos se reducen al separar eventos.
|
|
||||||
- La publicación puede ser atómica y cacheable.
|
|
||||||
- Es posible reconstruir el sitio desde cero.
|
|
||||||
|
|
||||||
## Consecuencias negativas
|
|
||||||
|
|
||||||
- No hay edición web en tiempo real.
|
|
||||||
- Cada escritura necesita sincronización Git.
|
|
||||||
- Con volúmenes muy altos de eventos habrá que medir rendimiento y quizá archivar.
|
|
||||||
- La coherencia depende de CI, validación y disciplina de ramas.
|
|
||||||
|
|
||||||
## Alternativas descartadas
|
|
||||||
|
|
||||||
- `localStorage`: no compartido, fácil de perder y no auditable.
|
|
||||||
- Archivo JSON central mutable: conflictos frecuentes entre administradores.
|
|
||||||
- Base de datos desde el MVP: complejidad desproporcionada para el flujo y volumen iniciales.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# ADR-0002: Identidad por llave SSH y prueba mediante commit firmado
|
|
||||||
|
|
||||||
- **Estado:** aceptada
|
|
||||||
- **Fecha:** 2026-08-12
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
Los administradores ya usan llaves SSH y los agentes operan en su sesión local. Se busca evitar un sistema adicional de usuarios y contraseñas, pero una llave pública almacenada no basta para probar que una operación la realizó quien posee la privada.
|
|
||||||
|
|
||||||
## Decisión
|
|
||||||
|
|
||||||
- El registro `data/admins.json` asocia una o más huellas públicas con cada administrador.
|
|
||||||
- La CLI resuelve la identidad a partir de una llave local.
|
|
||||||
- El contenido conserva `admin_id` y huella para trazabilidad.
|
|
||||||
- Las escrituras de producción usan `--commit` para crear un commit Git firmado por SSH.
|
|
||||||
- `config/allowed_signers` se genera desde administradores activos.
|
|
||||||
- CI y protección de rama verifican firmas y autorización.
|
|
||||||
|
|
||||||
## Consecuencias positivas
|
|
||||||
|
|
||||||
- Reutiliza la práctica operativa existente.
|
|
||||||
- Evita secretos de aplicación dentro del repositorio.
|
|
||||||
- La evidencia se integra con el historial Git.
|
|
||||||
- Los agentes pueden usar el mismo contrato sin conocer la llave privada.
|
|
||||||
|
|
||||||
## Consecuencias negativas
|
|
||||||
|
|
||||||
- La configuración inicial de Git/OpenSSH requiere cuidado.
|
|
||||||
- Rotación y revocación deben gobernarse explícitamente.
|
|
||||||
- Una operación sin `--commit` queda atribuida sólo de manera declarativa.
|
|
||||||
- El proveedor Git debe aplicar políticas de firma y rama.
|
|
||||||
|
|
||||||
## Regla de interpretación
|
|
||||||
|
|
||||||
La huella es identidad declarada; una firma válida y autorizada es evidencia criptográfica. Nunca deben tratarse como equivalentes.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# ADR-0003: Revisiones por hash y conjunto capturado
|
|
||||||
|
|
||||||
- **Estado:** aceptada
|
|
||||||
- **Fecha:** 2026-08-12
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
|
|
||||||
Un booleano `revisada` no indica quién revisó ni qué versión vio. Además, añadir un administrador meses después podría reabrir todo el histórico si la política se recalculara siempre contra el equipo actual.
|
|
||||||
|
|
||||||
## Decisión
|
|
||||||
|
|
||||||
Al crear una memoria:
|
|
||||||
|
|
||||||
- se captura el conjunto de revisores requerido;
|
|
||||||
- se calcula un hash SHA-256 sobre la versión de contenido;
|
|
||||||
- cada revisión registra administrador, huella, fecha y hash;
|
|
||||||
- sólo cuentan revisiones cuyo hash coincide con el actual;
|
|
||||||
- la cola se completa cuando todos los IDs capturados tienen un evento válido.
|
|
||||||
|
|
||||||
## Consecuencias positivas
|
|
||||||
|
|
||||||
- Editar una memoria invalida automáticamente el consenso anterior.
|
|
||||||
- La cola histórica no cambia al incorporar administradores futuros.
|
|
||||||
- Es posible explicar exactamente por qué una entrada está pendiente.
|
|
||||||
- Los eventos permanecen inmutables y auditables.
|
|
||||||
|
|
||||||
## Consecuencias negativas
|
|
||||||
|
|
||||||
- Corregir incluso un detalle menor puede reabrir la revisión.
|
|
||||||
- El equipo debe decidir cuándo una corrección editorial merece conservar o repetir consenso; el MVP prioriza seguridad y vuelve a abrir siempre.
|
|
||||||
- Bajas de personal requieren una política de excepción explícita, todavía no automatizada.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"section": "manuales",
|
|
||||||
"title": "Reemplazo seguro de un nodo de cómputo",
|
|
||||||
"description": "Runbook para drenar, reemplazar y reincorporar un nodo",
|
|
||||||
"tags": ["runbook", "hardware"],
|
|
||||||
"systems": ["compute", "kubernetes"],
|
|
||||||
"weight": 20,
|
|
||||||
"body_markdown": "## Prerrequisitos\n\n- Ventana aprobada.\n- Capacidad suficiente en los nodos restantes.\n- Acceso fuera de banda probado.\n\n## Procedimiento\n\n1. Confirmar salud del clúster.\n2. Drenar el nodo según la política de cargas.\n3. Verificar reubicación.\n4. Reemplazar el equipo.\n5. Reincorporar y validar.\n\n## Reversión\n\nRehabilitar el nodo anterior únicamente cuando sea seguro y documentar cualquier desviación.\n"
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Actualizar firmware del switch spine-01",
|
|
||||||
"description": "Cambio controlado y validación de enlaces del spine principal",
|
|
||||||
"tags": ["red", "firmware"],
|
|
||||||
"systems": ["spine-01"],
|
|
||||||
"risk": "high",
|
|
||||||
"ticket": "CHG-1042",
|
|
||||||
"review_required": true,
|
|
||||||
"review_policy": "all-active-except-author",
|
|
||||||
"context": "La versión instalada contiene una corrección aprobada para pérdida intermitente de telemetría.",
|
|
||||||
"changes": [
|
|
||||||
"Respaldé la configuración vigente.",
|
|
||||||
"Instalé la imagen aprobada y reinicié durante la ventana de mantenimiento."
|
|
||||||
],
|
|
||||||
"commands": [
|
|
||||||
"show version",
|
|
||||||
"show interfaces status"
|
|
||||||
],
|
|
||||||
"validation": [
|
|
||||||
"Todos los uplinks quedaron en estado up.",
|
|
||||||
"No aparecieron errores nuevos en los registros durante 20 minutos."
|
|
||||||
],
|
|
||||||
"rollback": "Arrancar la imagen anterior y restaurar la configuración respaldada.",
|
|
||||||
"duration_minutes": 75,
|
|
||||||
"agent": "claude-code"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Estandarizar observabilidad del clúster",
|
|
||||||
"description": "Unificar métricas, registros, alertas y tableros operativos",
|
|
||||||
"objective": "Lograr cobertura observable y criterios de alerta consistentes para todos los componentes críticos.",
|
|
||||||
"owners": [],
|
|
||||||
"target_date": "2026-12-15",
|
|
||||||
"status": "planned",
|
|
||||||
"tags": ["observabilidad"],
|
|
||||||
"systems": ["prometheus", "loki", "grafana"],
|
|
||||||
"scope": [
|
|
||||||
"Inventario de alertas",
|
|
||||||
"Dashboards base",
|
|
||||||
"Retención y capacidad"
|
|
||||||
],
|
|
||||||
"out_of_scope": [
|
|
||||||
"Sustituir el sistema corporativo de incidentes"
|
|
||||||
],
|
|
||||||
"milestones": [
|
|
||||||
"Completar inventario",
|
|
||||||
"Publicar estándares",
|
|
||||||
"Migrar componentes críticos"
|
|
||||||
],
|
|
||||||
"success_criteria": [
|
|
||||||
"100% de nodos reporta métricas base.",
|
|
||||||
"Todas las alertas críticas tienen owner y runbook."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Probar restauración de etcd",
|
|
||||||
"description": "Ejercicio controlado de recuperación del plano de control",
|
|
||||||
"project_id": "",
|
|
||||||
"priority": "high",
|
|
||||||
"assignees": [],
|
|
||||||
"estimate_minutes": 120,
|
|
||||||
"due": "2026-09-15",
|
|
||||||
"tags": ["backup", "dr"],
|
|
||||||
"systems": ["etcd"],
|
|
||||||
"objective": "Validar el RTO y corregir el runbook de restauración.",
|
|
||||||
"acceptance_criteria": [
|
|
||||||
"La restauración termina sin pérdida fuera del punto respaldado.",
|
|
||||||
"El RTO medido es menor a 60 minutos.",
|
|
||||||
"El manual refleja todos los pasos observados."
|
|
||||||
],
|
|
||||||
"notes": "Ejecutar en un entorno aislado con snapshots aprobados."
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Runbooks ejecutables
|
||||||
|
|
||||||
|
Esta carpeta vive **fuera** de `content/`, así que Zola nunca la construye ni
|
||||||
|
la publica (confirmado en `zola.toml`: no hay `content_dir` alterno). Es la
|
||||||
|
razón por la que aquí sí se pueden usar atributos de celda de Runme sin
|
||||||
|
arriesgar el front matter TOML ni el resaltado de sintaxis que sí usa
|
||||||
|
`content/manuales/`.
|
||||||
|
|
||||||
|
## Cómo abrir un runbook
|
||||||
|
|
||||||
|
- **Como notebook (Runme, VS Code):** instale la extensión recomendada en
|
||||||
|
[`.vscode/extensions.json`](../.vscode/extensions.json) (`stateful.runme`) y
|
||||||
|
abra el archivo `.md` normalmente; VS Code lo mostrará como notebook.
|
||||||
|
- **Como Markdown normal:** clic derecho sobre la pestaña → "Reopen Editor
|
||||||
|
With..." → "Text Editor". Ningún runbook depende de estar en modo notebook
|
||||||
|
para ser legible o copiable.
|
||||||
|
- **Por CLI:** `runme run --filename runbooks/<archivo>.md <nombre-de-celda>`.
|
||||||
|
- **Sin Runme instalado:** copie el bloque de comando y ejecútelo en su
|
||||||
|
terminal; ninguna celda usa sintaxis que no sea shell/`make`/`clusterlog`
|
||||||
|
normal.
|
||||||
|
|
||||||
|
## Convención de nombres
|
||||||
|
|
||||||
|
`kebab-case`, prefijo de proyecto, único en **todo** el repositorio (no sólo
|
||||||
|
dentro de un archivo): `clusterlog-sync-check`, `clusterlog-validate-local`,
|
||||||
|
`weekly-review-mine`. `clusterlog validate` (código en
|
||||||
|
[`internal/clusterlog/runbooks.go`](https://github.com/fenandosr/clusterlog/blob/main/internal/clusterlog/runbooks.go)
|
||||||
|
del motor) falla si dos celdas repiten un nombre.
|
||||||
|
|
||||||
|
## Taxonomía de etiquetas
|
||||||
|
|
||||||
|
Toda celda debe declarar al menos una etiqueta de cada categoría:
|
||||||
|
|
||||||
|
| Categoría | Valores | Obligatoria |
|
||||||
|
|---|---|---|
|
||||||
|
| Audiencia | `role-developer`, `role-operator`, `role-reviewer`, `role-owner` | sí |
|
||||||
|
| Entorno | `env-local`, `env-ci`, `env-staging`, `env-production` | sí |
|
||||||
|
| Riesgo | `risk-read-only`, `risk-mutating`, `risk-destructive` | sí |
|
||||||
|
| Ejecución automatizada | `ci-safe`, `manual-only` | no (pero mutuamente excluyente con `risk-mutating`/`risk-destructive`) |
|
||||||
|
|
||||||
|
Una celda `risk-mutating` o `risk-destructive` **debe** llevar
|
||||||
|
`manual-only`, `"interactive":"true"` y `"excludeFromRunAll":true`, y
|
||||||
|
**no puede** llevar `ci-safe` al mismo tiempo. `clusterlog validate` hace
|
||||||
|
cumplir esto — no es sólo una convención de estilo.
|
||||||
|
|
||||||
|
## Qué no va en una celda
|
||||||
|
|
||||||
|
Ver ADR-0004 y ADR-0005 en el
|
||||||
|
[repositorio del motor](https://github.com/fenandosr/clusterlog/tree/main/docs/adr).
|
||||||
|
En resumen: una celda invoca `make`, `./bin/clusterlog` o un script existente;
|
||||||
|
no reimplementa validación, autorización, rollback ni gestión de secretos.
|
||||||
|
|
||||||
|
## Esta es una instancia, no el motor
|
||||||
|
|
||||||
|
Esta carpeta y `RUNBOOK.md` en la raíz operan sobre **datos reales de este
|
||||||
|
deployment** (`content/`, `data/`, `config/`). El desarrollo del propio
|
||||||
|
`clusterlog` (compilar, probar, `bootstrap-instance`) vive en
|
||||||
|
[fenandosr/clusterlog](https://github.com/fenandosr/clusterlog); su
|
||||||
|
`.clusterlog-version` en la raíz de este repositorio fija qué versión del
|
||||||
|
motor usa esta instancia.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# <Título del runbook>
|
||||||
|
|
||||||
|
> Copie este archivo, renómbrelo en `kebab-case` y borre los comentarios
|
||||||
|
> `> así`. No borre las secciones: si alguna no aplica, escriba "No aplica" y
|
||||||
|
> explique por qué en una línea.
|
||||||
|
|
||||||
|
## Título
|
||||||
|
|
||||||
|
`<slug-del-runbook>`
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
> Qué logra este runbook en una o dos frases. Si no puede explicarlo en dos
|
||||||
|
> frases, probablemente son dos runbooks.
|
||||||
|
|
||||||
|
## Propietario
|
||||||
|
|
||||||
|
> Rol o persona responsable de mantenerlo actualizado.
|
||||||
|
|
||||||
|
## Audiencia
|
||||||
|
|
||||||
|
> Uno o más de: `role-developer`, `role-operator`, `role-reviewer`, `role-owner`.
|
||||||
|
|
||||||
|
## Entornos permitidos
|
||||||
|
|
||||||
|
> Uno o más de: `env-local`, `env-ci`, `env-staging`, `env-production`.
|
||||||
|
|
||||||
|
## Clasificación de riesgo
|
||||||
|
|
||||||
|
> `risk-read-only`, `risk-mutating` o `risk-destructive`, por celda — no
|
||||||
|
> necesariamente una sola clasificación para todo el documento.
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
> Herramientas, variables de entorno, accesos y estado previo necesarios.
|
||||||
|
> Distinga:
|
||||||
|
> - **Observación**: qué debe ser cierto antes de empezar.
|
||||||
|
> - **Hipótesis**: qué se espera que pase, si aplica (para runbooks de
|
||||||
|
> diagnóstico).
|
||||||
|
|
||||||
|
## Variables requeridas
|
||||||
|
|
||||||
|
> Nombre, propósito y si es secreta (nunca ponga el valor aquí; use
|
||||||
|
> `.env.example` como referencia de nombres).
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
> Celda de sólo lectura que confirma que el entorno está listo, sin instalar
|
||||||
|
> ni mutar nada.
|
||||||
|
|
||||||
|
```sh {"name":"template-preflight-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
echo "reemplace esto por una comprobación real de prerrequisitos"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Procedimiento
|
||||||
|
|
||||||
|
> Distinga explícitamente:
|
||||||
|
> - **Acción**: qué comando se ejecuta.
|
||||||
|
> - **Resultado esperado**: qué debería observarse si funcionó.
|
||||||
|
|
||||||
|
Ejemplo de celda de sólo lectura (segura para CI):
|
||||||
|
|
||||||
|
```sh {"name":"template-safe-example","tag":"role-developer,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
echo "una celda de sólo lectura va aquí"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo conceptual de celda que muta estado (nunca copie literalmente sin
|
||||||
|
adaptar prerrequisitos y validación):
|
||||||
|
|
||||||
|
```sh {"name":"template-mutating-example","tag":"role-operator,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
|
||||||
|
echo "una celda que muta estado real va aquí; nunca risk-mutating + ci-safe"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
> Cómo se confirma que el resultado observado es el esperado. Comandos
|
||||||
|
> concretos, no "revisar que todo esté bien".
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
> Cómo revertir, si aplica. Si no hay reversión directa, dígalo explícitamente
|
||||||
|
> y describa el procedimiento de corrección manual.
|
||||||
|
|
||||||
|
## Registro en Clusterlog
|
||||||
|
|
||||||
|
> Qué memoria, tarea o commit debe generarse fuera de Runme como evidencia
|
||||||
|
> canónica de que esto se ejecutó.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
|
||||||
|
> Qué guardar como prueba (salida JSON, SHA de commit, ID de memoria).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
> Enlaces a `CLI.md`, `SECURITY.md`, ADRs u otros runbooks relacionados.
|
||||||
|
|
||||||
|
## Limitaciones
|
||||||
|
|
||||||
|
> Qué este runbook explícitamente no cubre, y por qué.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Reunión semanal de revisión — versión ejecutable
|
||||||
|
|
||||||
|
Este runbook es el complemento ejecutable de
|
||||||
|
[`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md), que conserva la agenda y
|
||||||
|
el criterio de la reunión. Los comandos de preparación y cierre viven aquí
|
||||||
|
una sola vez; `docs/WEEKLY_REVIEW.md` enlaza a este archivo en vez de repetir
|
||||||
|
los bloques de shell.
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Preparar y cerrar la reunión semanal sin copiar comandos a mano.
|
||||||
|
|
||||||
|
## Propietario
|
||||||
|
|
||||||
|
Cada administrador ejecuta su propia preparación con su propia llave.
|
||||||
|
|
||||||
|
## Audiencia
|
||||||
|
|
||||||
|
`role-reviewer`, `role-owner`.
|
||||||
|
|
||||||
|
## Entornos permitidos
|
||||||
|
|
||||||
|
`env-local` únicamente. Ningún comando de este runbook debe correr en CI:
|
||||||
|
todos requieren identidad SSH personal o mutan la cola de revisión.
|
||||||
|
|
||||||
|
## Clasificación de riesgo
|
||||||
|
|
||||||
|
Preparación: `risk-read-only`. Marcar una revisión: `risk-mutating`.
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- `CLUSTERLOG_SSH_KEY` apuntando a su llave pública registrada.
|
||||||
|
- Haber leído las memorias pendientes antes de marcarlas.
|
||||||
|
|
||||||
|
## Variables requeridas
|
||||||
|
|
||||||
|
- `CLUSTERLOG_SSH_KEY`: ruta a su llave pública.
|
||||||
|
- `MEM_ID`: el `entry_id` exacto de la memoria que va a marcar (por ejemplo
|
||||||
|
`MEM-20260825-ABC123`), sólo para la celda de cierre.
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-identity","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
|
||||||
|
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
|
||||||
|
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" identity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Procedimiento
|
||||||
|
|
||||||
|
### 1. Preparación individual
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-pending","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
|
||||||
|
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
|
||||||
|
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" review list --mine
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-my-tasks","tag":"role-reviewer,env-local,risk-read-only","interactive":"false"}
|
||||||
|
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
|
||||||
|
./bin/clusterlog --root . --json --ssh-key "$CLUSTERLOG_SSH_KEY" task list --mine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Marcar una memoria como revisada (durante o después de la reunión)
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-mark","tag":"role-reviewer,env-production,risk-mutating,manual-only","interactive":"true","excludeFromRunAll":true}
|
||||||
|
: "${CLUSTERLOG_SSH_KEY:?defina CLUSTERLOG_SSH_KEY}"
|
||||||
|
: "${MEM_ID:?defina MEM_ID con el entry_id exacto de la memoria, por ejemplo MEM-20260825-ABC123}"
|
||||||
|
./bin/clusterlog --root . --json --commit --ssh-key "$CLUSTERLOG_SSH_KEY" \
|
||||||
|
review mark "$MEM_ID" --note "Revisado en reunión semanal"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerrequisito adicional de esta celda: haber leído y entendido la memoria
|
||||||
|
completa, no sólo su título. `env-production` porque un `--commit` exitoso
|
||||||
|
queda en el historial de Git compartido — no es una operación de laboratorio.
|
||||||
|
|
||||||
|
### 3. Cierre
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-close-check","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
make sync-check
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-close-validate","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
make validate-local
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh {"name":"weekly-review-close-verify","tag":"role-owner,env-local,risk-read-only,ci-safe","interactive":"false"}
|
||||||
|
./bin/clusterlog --root . verify commit HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
`review list --mine` debe quedar vacío (o con causa y fecha de seguimiento
|
||||||
|
documentada aparte, según `docs/WEEKLY_REVIEW.md`).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`review mark` no tiene reversión directa: agrega un evento inmutable en
|
||||||
|
`data/review-events/`. Si se marcó por error, no se edita el evento; se
|
||||||
|
documenta el error en la siguiente memoria y se decide en equipo si se
|
||||||
|
requiere una excepción de política (ver "Preguntas de gobierno" en
|
||||||
|
`HOJA_DE_PROYECTO.md`).
|
||||||
|
|
||||||
|
## Registro en Clusterlog
|
||||||
|
|
||||||
|
`review mark --commit` ya deja su propio commit firmado; no hace falta una
|
||||||
|
memoria adicional sólo por marcar una revisión.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
|
||||||
|
Salida JSON de `review mark` (contiene el hash de contenido revisado) y el
|
||||||
|
SHA del commit que produce.
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [`docs/WEEKLY_REVIEW.md`](../docs/WEEKLY_REVIEW.md)
|
||||||
|
- [`CLI.md`](../CLI.md), sección 7 (Revisión).
|
||||||
|
|
||||||
|
## Limitaciones
|
||||||
|
|
||||||
|
Ninguna celda de este runbook es `ci-safe`: todas requieren una llave SSH
|
||||||
|
personal o mutan estado compartido. Esto es intencional, no una omisión.
|
||||||
@@ -42,8 +42,48 @@ cp "$SOURCE/theme.toml" "$DEST/theme.toml"
|
|||||||
[[ -f "$SOURCE/README.md" ]] && cp "$SOURCE/README.md" "$DEST/UPSTREAM_README.md"
|
[[ -f "$SOURCE/README.md" ]] && cp "$SOURCE/README.md" "$DEST/UPSTREAM_README.md"
|
||||||
printf '%s\n' "$REF" > "$DEST/.upstream-revision"
|
printf '%s\n' "$REF" > "$DEST/.upstream-revision"
|
||||||
|
|
||||||
|
# Ninguna plantilla local usa una sola clase de Bootstrap (auditado: cero
|
||||||
|
# coincidencias de .btn/.navbar/.row/.span*/.modal/etc.), así que el
|
||||||
|
# site.scss original del tema (que importa el bundle de Bootstrap 2 de
|
||||||
|
# ~103 KB) se reemplaza por un reset mínimo propio que cubre lo que
|
||||||
|
# ops.css sí necesita: el fondo negro de <code>/<pre> y unos resets base.
|
||||||
|
# static/ tampoco lo referencia nada local, así que se descarta entero.
|
||||||
|
cat > "$DEST/sass/site.scss" <<'SCSS'
|
||||||
|
// Reset propio: reemplaza el bundle de Bootstrap 2 del tema, que ninguna
|
||||||
|
// plantilla local usaba (auditado). Cubre solo lo que ops.css necesita.
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body { margin: 0; }
|
||||||
|
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0 0 1em; }
|
||||||
|
ul, ol { margin: 0 0 1em; padding: 0; list-style: none; }
|
||||||
|
img, svg, video { max-width: 100%; height: auto; display: block; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
hr { border: 0; border-top: 1px solid currentColor; }
|
||||||
|
a { text-decoration: underline; }
|
||||||
|
:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
padding: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: 8.5px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
SCSS
|
||||||
|
rm -rf "$DEST/static"
|
||||||
|
mkdir -p "$DEST/static"
|
||||||
|
|
||||||
cat <<MSG
|
cat <<MSG
|
||||||
Tema zola.386 preparado en themes/zola.386
|
Tema zola.386 preparado en themes/zola.386
|
||||||
Revisión fijada: $REF
|
Revisión fijada: $REF
|
||||||
Se conservaron Sass y archivos estáticos; las plantillas locales modernas tienen precedencia.
|
Se conservaron Sass y archivos estáticos; las plantillas locales modernas tienen precedencia.
|
||||||
|
site.scss reemplazado por un reset propio (~1 KB) en vez del bundle de Bootstrap 2 (~103 KB); static/ podado a vacío.
|
||||||
MSG
|
MSG
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
CLI="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
|
|
||||||
|
|
||||||
for command in jq mktemp cp find; do
|
|
||||||
if ! command -v "$command" >/dev/null 2>&1; then
|
|
||||||
echo "Falta el comando requerido para la prueba E2E: $command" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ ! -x "$CLI" ]]; then
|
|
||||||
echo "No existe un binario ejecutable en $CLI; ejecute make build." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
|
||||||
cleanup() { rm -rf "$TMP"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
cp -a "$ROOT/." "$TMP/"
|
|
||||||
CLI="$TMP/bin/clusterlog"
|
|
||||||
|
|
||||||
for section in memorias tareas proyectos; do
|
|
||||||
find "$TMP/content/$section" -maxdepth 1 -type f -name '*.md' ! -name '_index.md' -delete
|
|
||||||
done
|
|
||||||
find "$TMP/data/review-events" -type f ! -name '.gitkeep' -delete
|
|
||||||
find "$TMP/data/task-events" -type f ! -name '.gitkeep' -delete
|
|
||||||
cat > "$TMP/data/admins.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"review_policy": {"default": "all-active", "include_author": true},
|
|
||||||
"admins": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
: > "$TMP/config/allowed_signers"
|
|
||||||
|
|
||||||
# Material público ficticio: sólo ejercita resolución de huella, nunca firma.
|
|
||||||
cat > "$TMP/ana.pub" <<'KEY'
|
|
||||||
ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYW5hLTAwMDAwMDAwMDAwMDAwMA== ana@test
|
|
||||||
KEY
|
|
||||||
cat > "$TMP/bob.pub" <<'KEY'
|
|
||||||
ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYm9iLTAwMDAwMDAwMDAwMDAwMA== bob@test
|
|
||||||
KEY
|
|
||||||
|
|
||||||
run_json() {
|
|
||||||
local output
|
|
||||||
output=$("$@")
|
|
||||||
jq -e '.ok == true' <<<"$output" >/dev/null
|
|
||||||
printf '%s' "$output"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
|
|
||||||
admin add --id ana --name 'Ana Admin' --email ana@example.org \
|
|
||||||
--key "$TMP/ana.pub" --roles admin >/dev/null
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
|
|
||||||
admin add --id bob --name 'Bob Admin' --email bob@example.org \
|
|
||||||
--key "$TMP/bob.pub" --roles admin >/dev/null
|
|
||||||
|
|
||||||
cat > "$TMP/memory.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"title": "Actualizar kubelet de nodos de prueba",
|
|
||||||
"description": "Cambio controlado en dos nodos",
|
|
||||||
"tags": ["kubernetes", "mantenimiento"],
|
|
||||||
"systems": ["worker-test-01", "worker-test-02"],
|
|
||||||
"risk": "medium",
|
|
||||||
"review_required": true,
|
|
||||||
"review_policy": "all-active",
|
|
||||||
"context": "Validar la actualización antes de la ventana productiva.",
|
|
||||||
"changes": ["Drenar nodos", "Actualizar paquete", "Reintegrar nodos"],
|
|
||||||
"validation": ["Nodos Ready", "Pods reprogramados"],
|
|
||||||
"rollback": "Reinstalar la versión anterior."
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
memory=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
|
|
||||||
memory create --from-json "$TMP/memory.json")
|
|
||||||
mem_id=$(jq -er '.data.entry_id' <<<"$memory")
|
|
||||||
mem_path=$(jq -er '.data.path' <<<"$memory")
|
|
||||||
|
|
||||||
queue=$(run_json "$CLI" --root "$TMP" --json review list)
|
|
||||||
[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]]
|
|
||||||
[[ $(jq -er '.data.items[0].required_count' <<<"$queue") -eq 2 ]]
|
|
||||||
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]]
|
|
||||||
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
|
|
||||||
review mark "$mem_id" --note 'Revisado por Ana' >/dev/null
|
|
||||||
queue=$(run_json "$CLI" --root "$TMP" --json review list)
|
|
||||||
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 1 ]]
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
|
|
||||||
review mark "$mem_id" --note 'Revisado por Bob' >/dev/null
|
|
||||||
queue=$(run_json "$CLI" --root "$TMP" --json review list)
|
|
||||||
[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]]
|
|
||||||
|
|
||||||
# Cambiar el contenido invalida las confirmaciones de la versión anterior.
|
|
||||||
printf '\n## Seguimiento\n\nSe agregó una observación posterior.\n' >> "$mem_path"
|
|
||||||
run_json "$CLI" --root "$TMP" --json sync >/dev/null
|
|
||||||
queue=$(run_json "$CLI" --root "$TMP" --json review list)
|
|
||||||
[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]]
|
|
||||||
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]]
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" review mark "$mem_id" >/dev/null
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" review mark "$mem_id" >/dev/null
|
|
||||||
queue=$(run_json "$CLI" --root "$TMP" --json review list)
|
|
||||||
[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]]
|
|
||||||
|
|
||||||
cat > "$TMP/project.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"title": "Renovar plataforma de monitoreo",
|
|
||||||
"description": "Actualizar y normalizar la observabilidad",
|
|
||||||
"objective": "Reducir puntos ciegos operativos.",
|
|
||||||
"owners": ["ana"],
|
|
||||||
"target_date": "2099-12-01",
|
|
||||||
"status": "active",
|
|
||||||
"tags": ["observabilidad"],
|
|
||||||
"systems": ["prometheus"],
|
|
||||||
"scope": ["Métricas y alertas"],
|
|
||||||
"out_of_scope": ["Mesa de ayuda"],
|
|
||||||
"milestones": ["Inventario", "Migración"],
|
|
||||||
"success_criteria": ["Cobertura completa de nodos"]
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
project=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
|
|
||||||
project create --from-json "$TMP/project.json")
|
|
||||||
project_id=$(jq -er '.data.project_id' <<<"$project")
|
|
||||||
|
|
||||||
cat > "$TMP/task.json" <<JSON
|
|
||||||
{
|
|
||||||
"title": "Migrar alertas de nodos",
|
|
||||||
"description": "Mover reglas al repositorio estándar",
|
|
||||||
"project_id": "$project_id",
|
|
||||||
"priority": "high",
|
|
||||||
"assignees": ["bob"],
|
|
||||||
"estimate_minutes": 60,
|
|
||||||
"due": "2099-11-01",
|
|
||||||
"tags": ["alertas"],
|
|
||||||
"systems": ["prometheus"],
|
|
||||||
"objective": "Centralizar reglas.",
|
|
||||||
"acceptance_criteria": ["Reglas validadas"],
|
|
||||||
"notes": "Sin cambios productivos."
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
task=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
|
|
||||||
task create --from-json "$TMP/task.json")
|
|
||||||
task_id=$(jq -er '.data.task_id' <<<"$task")
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
|
|
||||||
task start "$task_id" --note 'Inicio' >/dev/null
|
|
||||||
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
|
|
||||||
task adjust "$task_id" --minutes 15 --note 'Trabajo previo' >/dev/null
|
|
||||||
done=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
|
|
||||||
task done "$task_id" --note 'Terminado')
|
|
||||||
[[ $(jq -er '.data.task.status' <<<"$done") == done ]]
|
|
||||||
[[ $(jq -er '.data.task.actual_minutes' <<<"$done") -ge 15 ]]
|
|
||||||
[[ $(jq -er '.data.task.running_by | length' <<<"$done") -eq 0 ]]
|
|
||||||
|
|
||||||
tasks=$(run_json "$CLI" --root "$TMP" --json task list --project "$project_id")
|
|
||||||
[[ $(jq -er '.data.count' <<<"$tasks") -eq 1 ]]
|
|
||||||
[[ $(jq -er '.data.items[0].status' <<<"$tasks") == done ]]
|
|
||||||
projects=$(run_json "$CLI" --root "$TMP" --json project list --status active)
|
|
||||||
[[ $(jq -er '.data.count' <<<"$projects") -eq 1 ]]
|
|
||||||
run_json "$CLI" --root "$TMP" --json sync --check >/dev/null
|
|
||||||
validation=$(run_json "$CLI" --root "$TMP" --json validate --skip-zola)
|
|
||||||
[[ $(jq -er '.data.valid' <<<"$validation") == true ]]
|
|
||||||
[[ $(find "$TMP/data/review-events" -type f -name '*.json' | wc -l) -eq 4 ]]
|
|
||||||
[[ $(find "$TMP/data/task-events" -type f -name '*.json' | wc -l) -ge 4 ]]
|
|
||||||
|
|
||||||
printf 'E2E OK: memoria %s, proyecto %s, tarea %s; revisión por hash y eventos independientes verificados.\n' \
|
|
||||||
"$mem_id" "$project_id" "$task_id"
|
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Descarga el binario del release fijado del motor (fenandosr/clusterlog) y
|
||||||
|
# verifica su checksum contra checksums.txt del mismo release. Nunca usa
|
||||||
|
# `latest`: la versión viene de .clusterlog-version.
|
||||||
|
#
|
||||||
|
# Las plantillas ya no se descargan aparte (desde v0.2.0 del motor viajan
|
||||||
|
# embebidas en el propio binario): use "clusterlog templates extract"
|
||||||
|
# después de este script, ver el target "templates" del Makefile.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENGINE_REPO="${CLUSTERLOG_ENGINE_REPO:-fenandosr/clusterlog}"
|
||||||
|
VERSION="$(cat "$ROOT/.clusterlog-version")"
|
||||||
|
DEST="${1:?uso: fetch-engine.sh <destino-del-binario>}"
|
||||||
|
|
||||||
|
BASE_URL="https://github.com/${ENGINE_REPO}/releases/download/${VERSION}"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
|
arch="$(uname -m)"
|
||||||
|
case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
||||||
|
asset="clusterlog_${os}_${arch}.tar.gz"
|
||||||
|
|
||||||
|
echo "Descargando $asset (clusterlog $VERSION, $ENGINE_REPO)..."
|
||||||
|
curl -fsSL -o "$TMP/checksums.txt" "$BASE_URL/checksums.txt"
|
||||||
|
curl -fsSL -o "$TMP/$asset" "$BASE_URL/$asset"
|
||||||
|
|
||||||
|
expected="$(grep " $asset\$" "$TMP/checksums.txt" | awk '{print $1}')"
|
||||||
|
if [ -z "$expected" ]; then
|
||||||
|
echo "ERROR: $asset no aparece en checksums.txt del release $VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
computed="$(sha256sum "$TMP/$asset" | awk '{print $1}')"
|
||||||
|
if [ "$computed" != "$expected" ]; then
|
||||||
|
echo "ERROR: checksum no coincide para $asset" >&2
|
||||||
|
echo " esperado: $expected" >&2
|
||||||
|
echo " obtenido: $computed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Checksum verificado."
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$DEST")"
|
||||||
|
tar xzf "$TMP/$asset" -C "$TMP" clusterlog
|
||||||
|
install -m 0755 "$TMP/clusterlog" "$DEST"
|
||||||
|
echo "Binario en $DEST ($("$DEST" version 2>/dev/null || true))"
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Comprobación de compatibilidad de los runbooks. No escribe nada en el
|
||||||
|
# repositorio. Las reglas duras (nombres únicos, etiquetas, aislamiento de
|
||||||
|
# celdas mutantes, secretos, referencias a scripts/targets inexistentes)
|
||||||
|
# viven en `clusterlog validate` (internal/clusterlog/runbooks.go) y corren
|
||||||
|
# siempre, con o sin Runme instalado. Este script agrega, por encima, un
|
||||||
|
# chequeo blando de que Runme puede parsear cada runbook de verdad.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BIN="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
|
||||||
|
|
||||||
|
echo "== Reglas de clusterlog validate (obligatorio) =="
|
||||||
|
"$BIN" --root "$ROOT" --json validate --skip-zola
|
||||||
|
|
||||||
|
if ! command -v runme >/dev/null 2>&1; then
|
||||||
|
echo "== runme no está instalado: se omite el chequeo de compatibilidad de Runme (make runme-install) =="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Compatibilidad con la versión fijada de Runme (blando) =="
|
||||||
|
status=0
|
||||||
|
files=("$ROOT/RUNBOOK.md")
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
files+=("$f")
|
||||||
|
done < <(find "$ROOT/runbooks" -name '*.md' -print0 2>/dev/null)
|
||||||
|
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
rel="${f#"$ROOT"/}"
|
||||||
|
if runme ls --filename "$f" >/dev/null 2>&1; then
|
||||||
|
echo " ok: $rel"
|
||||||
|
else
|
||||||
|
echo " ADVERTENCIA: runme no pudo parsear $rel" >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
echo "runme-check: advertencias de compatibilidad de Runme (no bloqueante)." >&2
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Corre cada celda ci-safe de RUNBOOK.md, una por una, extrayendo su script
|
||||||
|
# real con "runme run --dry-run <celda>" y ejecutándolo con bash — sin usar
|
||||||
|
# el motor de ejecución de Runme.
|
||||||
|
#
|
||||||
|
# Se intentó "runme run --tag ci-safe --all --skip-prompts" y también
|
||||||
|
# invocar cada celda por nombre sin --all: ambas formas abortan de
|
||||||
|
# inmediato en el runner real de esta instancia ("could not execute
|
||||||
|
# command: aborted", justo al entrar a "Parsing files..."), algo que no se
|
||||||
|
# pudo reproducir en ninguna terminal local (interactiva, con stdin/stdout
|
||||||
|
# redirigidos, con CI=true/TERM=dumb). "--dry-run" sí funciona en ambos
|
||||||
|
# lados: no dispara esa ruta de código porque nunca ejecuta nada él mismo,
|
||||||
|
# sólo imprime el script resuelto de la celda.
|
||||||
|
#
|
||||||
|
# Esto sacrifica que sea Runme quien de verdad ejecute el comando en CI,
|
||||||
|
# pero preserva lo esencial: el Markdown con atributos de celda sigue
|
||||||
|
# siendo la única fuente de verdad de qué corre, y localmente (fuera de
|
||||||
|
# este runner) "runme run <celda>" sigue funcionando de forma normal.
|
||||||
|
#
|
||||||
|
# La lista debe coincidir con las celdas "ci-safe" de RUNBOOK.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
CI_SAFE_CELLS=(
|
||||||
|
clusterlog-runme-preflight
|
||||||
|
clusterlog-sync-check
|
||||||
|
clusterlog-validate-local
|
||||||
|
clusterlog-verify-head
|
||||||
|
)
|
||||||
|
|
||||||
|
for cell in "${CI_SAFE_CELLS[@]}"; do
|
||||||
|
echo "== $cell =="
|
||||||
|
# --dry-run escribe el script resuelto a stderr, no a stdout.
|
||||||
|
runme run --filename RUNBOOK.md --dry-run "$cell" 2>&1 >/dev/null | bash
|
||||||
|
done
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Instala una versión fijada de Runme (nunca "latest") verificando su
|
||||||
|
# checksum SHA-256 contra los valores publicados en el release oficial.
|
||||||
|
# No sustituye ni toca bin/clusterlog. No usa curl|sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RUNME_VERSION="3.17.4"
|
||||||
|
INSTALL_DIR="${RUNME_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
BASE_URL="https://github.com/runmedev/runme/releases/download/v${RUNME_VERSION}"
|
||||||
|
|
||||||
|
# SHA-256 tomados del checksums.txt publicado en el release v3.17.4 y
|
||||||
|
# verificados de forma independiente (sha256sum) contra el tarball real
|
||||||
|
# durante la implementación de esta integración.
|
||||||
|
CHECKSUM_LINUX_X86_64="78af7ed33a84ff91a617a018886730038f10af8f73027451d322c21c417fed2c"
|
||||||
|
CHECKSUM_LINUX_ARM64="fd016b92115d7f77806c70619ee113dc951c49398863e54bc83eadb9d48665e4"
|
||||||
|
CHECKSUM_DARWIN_X86_64="7c69077ad7331725fa5224fab95562089a42c95baad42b130e1b0b3774ebc480"
|
||||||
|
CHECKSUM_DARWIN_ARM64="cba556395ca24ccee641ee2faa20cc5a4150826205371de354aafc4a4d830381"
|
||||||
|
|
||||||
|
os="$(uname -s)"
|
||||||
|
arch="$(uname -m)"
|
||||||
|
|
||||||
|
case "$os" in
|
||||||
|
Linux)
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) asset="runme_linux_x86_64.tar.gz"; checksum="$CHECKSUM_LINUX_X86_64" ;;
|
||||||
|
aarch64|arm64) asset="runme_linux_arm64.tar.gz"; checksum="$CHECKSUM_LINUX_ARM64" ;;
|
||||||
|
*) echo "Arquitectura Linux no soportada por este script: $arch" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) asset="runme_darwin_x86_64.tar.gz"; checksum="$CHECKSUM_DARWIN_X86_64" ;;
|
||||||
|
arm64) asset="runme_darwin_arm64.tar.gz"; checksum="$CHECKSUM_DARWIN_ARM64" ;;
|
||||||
|
*) echo "Arquitectura macOS no soportada por este script: $arch" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Sistema operativo no soportado por este script: $os" >&2
|
||||||
|
echo "En Windows, descargue manualmente runme_windows_{x86_64,arm64}.zip desde" >&2
|
||||||
|
echo " https://github.com/runmedev/runme/releases/tag/v${RUNME_VERSION}" >&2
|
||||||
|
echo "y verifique su checksum contra checksums.txt del mismo release." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if command -v runme >/dev/null 2>&1; then
|
||||||
|
current="$(runme --version 2>/dev/null | awk '{print $3}')"
|
||||||
|
if [ "$current" = "$RUNME_VERSION" ]; then
|
||||||
|
echo "runme $RUNME_VERSION ya está instalado en $(command -v runme); nada que hacer."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
echo "Descargando $asset (runme v${RUNME_VERSION})..."
|
||||||
|
curl -fsSL -o "$tmp_dir/$asset" "$BASE_URL/$asset"
|
||||||
|
|
||||||
|
echo "Verificando checksum SHA-256..."
|
||||||
|
computed="$(sha256sum "$tmp_dir/$asset" | awk '{print $1}')"
|
||||||
|
if [ "$computed" != "$checksum" ]; then
|
||||||
|
echo "ERROR: checksum no coincide para $asset" >&2
|
||||||
|
echo " esperado: $checksum" >&2
|
||||||
|
echo " obtenido: $computed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Checksum verificado."
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
tar xzf "$tmp_dir/$asset" -C "$tmp_dir" runme
|
||||||
|
install -m 0755 "$tmp_dir/runme" "$INSTALL_DIR/runme"
|
||||||
|
|
||||||
|
if [ -e "$INSTALL_DIR/clusterlog" ] || [ -e "$INSTALL_DIR/bin/clusterlog" ]; then
|
||||||
|
echo "ERROR: se detectó un binario 'clusterlog' en $INSTALL_DIR; abortando para no sobrescribirlo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "runme instalado en $INSTALL_DIR/runme"
|
||||||
|
"$INSTALL_DIR/runme" --version
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$INSTALL_DIR:"*) ;;
|
||||||
|
*) echo "Agregue $INSTALL_DIR a su PATH para usar 'runme' directamente." ;;
|
||||||
|
esac
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}404 · {{ config.title }}{% endblock title %}
|
|
||||||
{% block content %}<section class="ops-hero"><p class="ops-eyebrow">ERROR 404</p><h1>Ruta no encontrada</h1><p>La referencia no existe en la proyección actual de la bitácora.</p><p><a href="{{ get_url(path='@/_index.md') }}">Volver al inicio</a></p></section>{% endblock content %}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="{{ lang }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="{% block description %}{{ config.description }}{% endblock description %}">
|
|
||||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
|
||||||
<title>{% block title %}{{ config.title }}{% endblock title %}</title>
|
|
||||||
<link rel="stylesheet" href="{{ get_url(path='site.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ get_url(path='css/ops.css') }}">
|
|
||||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ get_url(path='atom.xml') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% set ops = load_data(path="data/generated/site_state.json", format="json") %}
|
|
||||||
<header class="ops-header">
|
|
||||||
<div class="container ops-header__inner">
|
|
||||||
<a class="ops-brand" href="{{ get_url(path='@/_index.md') }}">{{ config.extra.product_name }}</a>
|
|
||||||
<span class="ops-prompt" aria-hidden="true">root@cluster:~#</span>
|
|
||||||
<nav class="ops-nav" aria-label="Navegación principal">
|
|
||||||
<a href="{{ get_url(path='@/documentacion/_index.md') }}">Documentación</a>
|
|
||||||
<a href="{{ get_url(path='@/manuales/_index.md') }}">Manuales</a>
|
|
||||||
<a href="{{ get_url(path='@/memorias/_index.md') }}">Memorias</a>
|
|
||||||
<a href="{{ get_url(path='@/proyectos/_index.md') }}">Proyectos</a>
|
|
||||||
<a href="{{ get_url(path='@/tareas/_index.md') }}">Tareas</a>
|
|
||||||
<a class="ops-nav__review" href="{{ get_url(path='@/revision/_index.md') }}">Revisión <span>{{ ops.stats.pending_reviews }}</span></a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="container ops-search" role="search">
|
|
||||||
<label for="ops-search-input">Buscar en la bitácora</label>
|
|
||||||
<div class="ops-search__row">
|
|
||||||
<input id="ops-search-input" type="search" autocomplete="off" data-index-url="{{ get_url(path='search_index.es.json') }}" placeholder="título, sistema, comando, incidente…">
|
|
||||||
<kbd>/</kbd>
|
|
||||||
</div>
|
|
||||||
<div id="ops-search-results" class="ops-search-results" hidden></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="container ops-main">
|
|
||||||
{% block content %}{% endblock content %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="container ops-footer">
|
|
||||||
<span>{{ config.extra.footer_text }}</span>
|
|
||||||
<span>CLI: <code>{{ config.extra.product_short_name }}</code></span>
|
|
||||||
</footer>
|
|
||||||
<script defer src="{{ get_url(path='js/ops-search.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<section class="ops-hero">
|
|
||||||
<p class="ops-eyebrow">OPERACIONES / CONOCIMIENTO / AUDITORÍA</p>
|
|
||||||
<h1>{{ section.title }}</h1>
|
|
||||||
<div class="ops-lead">{{ section.content | safe }}</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ops-stats" aria-label="Estado actual">
|
|
||||||
<a class="ops-stat" href="{{ get_url(path='@/revision/_index.md') }}"><strong>{{ ops.stats.pending_reviews }}</strong><span>revisiones pendientes</span></a>
|
|
||||||
<a class="ops-stat" href="{{ get_url(path='@/tareas/_index.md') }}"><strong>{{ ops.stats.open_tasks }}</strong><span>tareas abiertas</span></a>
|
|
||||||
<a class="ops-stat" href="{{ get_url(path='@/proyectos/_index.md') }}"><strong>{{ ops.stats.active_projects }}</strong><span>proyectos activos</span></a>
|
|
||||||
<div class="ops-stat"><strong>{{ ops.stats.active_admins }}</strong><span>administradores activos</span></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ops-grid ops-grid--sections">
|
|
||||||
<a class="ops-panel" href="{{ get_url(path='@/documentacion/_index.md') }}"><span class="ops-panel__code">DOC</span><h2>Documentación</h2><p>Arquitectura y decisiones duraderas.</p></a>
|
|
||||||
<a class="ops-panel" href="{{ get_url(path='@/manuales/_index.md') }}"><span class="ops-panel__code">RUN</span><h2>Manuales</h2><p>Procedimientos repetibles y reversibles.</p></a>
|
|
||||||
<a class="ops-panel" href="{{ get_url(path='@/memorias/_index.md') }}"><span class="ops-panel__code">MEM</span><h2>Memorias</h2><p>Historia de operaciones y cambios.</p></a>
|
|
||||||
<a class="ops-panel" href="{{ get_url(path='@/proyectos/_index.md') }}"><span class="ops-panel__code">PRJ</span><h2>Proyectos</h2><p>Objetivos, alcance e hitos.</p></a>
|
|
||||||
<a class="ops-panel" href="{{ get_url(path='@/tareas/_index.md') }}"><span class="ops-panel__code">TSK</span><h2>Tareas</h2><p>Trabajo visible y tiempo registrado.</p></a>
|
|
||||||
<a class="ops-panel ops-panel--attention" href="{{ get_url(path='@/revision/_index.md') }}"><span class="ops-panel__code">REV</span><h2>Revisión semanal</h2><p>La agenda común del equipo.</p></a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ops-section-heading">
|
|
||||||
<div><p class="ops-eyebrow">ACTIVIDAD</p><h2>Memorias recientes</h2></div>
|
|
||||||
<a href="{{ get_url(path='@/memorias/_index.md') }}">Ver todas →</a>
|
|
||||||
</section>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for item in ops.recent_memories %}
|
|
||||||
{% if loop.index <= 6 %}
|
|
||||||
<article class="ops-list-item">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.date }}</span></div>
|
|
||||||
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
|
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
<div class="ops-tags">{% for tag in item.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if ops.recent_memories | length == 0 %}<p class="ops-empty">Todavía no hay memorias operativas.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header">
|
|
||||||
<p class="ops-eyebrow">HISTORIA TÉCNICA</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
|
|
||||||
<div class="ops-prose">{{ section.content | safe }}</div>
|
|
||||||
</header>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for page in section.pages %}
|
|
||||||
<article class="ops-list-item">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</span><span>{{ page.date }}</span><span>por {{ page.extra.author_id }}</span></div>
|
|
||||||
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
|
|
||||||
<p>{{ page.description }}</p>
|
|
||||||
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ page.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<article class="ops-article">
|
|
||||||
<header class="ops-page-header">
|
|
||||||
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / MEMORIA OPERATIVA</p>
|
|
||||||
<h1>{{ page.title }}</h1><p>{{ page.description }}</p>
|
|
||||||
<div class="ops-meta">
|
|
||||||
<span>Autor: <strong>{{ page.extra.author_id }}</strong></span>
|
|
||||||
<span>Fecha: {{ page.date }}</span>
|
|
||||||
<span>Riesgo: <strong class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</strong></span>
|
|
||||||
{% if page.extra.duration_minutes > 0 %}<span>Duración: {{ page.extra.duration_minutes }} min</span>{% endif %}
|
|
||||||
{% if page.extra.ticket %}<span>Referencia: {{ page.extra.ticket }}</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
|
|
||||||
</header>
|
|
||||||
{% if page.extra.review_required %}
|
|
||||||
<aside class="ops-callout"><strong>Revisión requerida.</strong> El estado se calcula contra el hash actual. Consulte <a href="{{ get_url(path='@/revision/_index.md') }}">Revisión semanal</a>.</aside>
|
|
||||||
{% endif %}
|
|
||||||
<div class="ops-prose">{{ page.content | safe }}</div>
|
|
||||||
<footer class="ops-source"><span>Huella SSH del autor:</span><code>{{ page.extra.author_fingerprint }}</code></footer>
|
|
||||||
</article>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ page.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<article class="ops-article">
|
|
||||||
<header class="ops-page-header">
|
|
||||||
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / {{ page.extra.kind }}</p>
|
|
||||||
<h1>{{ page.title }}</h1>
|
|
||||||
<p>{{ page.description }}</p>
|
|
||||||
<div class="ops-meta"><span>Autor: {{ page.extra.author_id }}</span><span>Fecha: {{ page.date }}</span></div>
|
|
||||||
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
|
|
||||||
</header>
|
|
||||||
<div class="ops-prose">{{ page.content | safe }}</div>
|
|
||||||
</article>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ page.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<article class="ops-article">
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / PROYECTO</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
|
|
||||||
<div class="ops-meta"><span>Estado: {{ page.extra.status }}</span>{% if page.extra.target_date %}<span>Fecha objetivo: {{ page.extra.target_date }}</span>{% endif %}<span>Responsables: {% for owner in page.extra.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if page.extra.owners | length == 0 %}Sin asignar{% endif %}</span></div></header>
|
|
||||||
<div class="ops-prose">{{ page.content | safe }}</div>
|
|
||||||
</article>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow">LÍNEAS DE TRABAJO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
|
|
||||||
<div class="ops-board">
|
|
||||||
{% for project in ops.projects %}
|
|
||||||
<article class="ops-card">
|
|
||||||
<div class="ops-card__top"><code>{{ project.project_id }}</code><span class="badge badge--status">{{ project.status }}</span></div>
|
|
||||||
<h2><a href="{{ project.permalink }}">{{ project.title }}</a></h2><p>{{ project.description }}</p>
|
|
||||||
<dl>{% if project.target_date %}<dt>Objetivo</dt><dd>{{ project.target_date }}</dd>{% endif %}<dt>Responsables</dt><dd>{% for owner in project.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if project.owners | length == 0 %}Sin asignar{% endif %}</dd></dl>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% if ops.projects | length == 0 %}<p class="ops-empty">No hay proyectos.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header">
|
|
||||||
<p class="ops-eyebrow">AGENDA DEL EQUIPO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
|
|
||||||
<div class="ops-prose">{{ section.content | safe }}</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="ops-callout">
|
|
||||||
<strong>Flujo de la reunión:</strong> abrir la memoria, discutirla y registrar cada confirmación con
|
|
||||||
<code>clusterlog --commit review mark <ID></code>. Una edición posterior cambia el hash y vuelve a abrir la revisión.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section aria-labelledby="review-queue-title">
|
|
||||||
<h2 id="review-queue-title">Memorias pendientes</h2>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for item in ops.review_queue %}
|
|
||||||
<article class="ops-list-item ops-list-item--review">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.reviewed_count }}/{{ item.required_count }} revisiones</span></div>
|
|
||||||
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
|
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
{% if item.configuration_error %}<p class="ops-error">{{ item.configuration_error }}</p>{% endif %}
|
|
||||||
<div class="ops-review-grid">
|
|
||||||
<div><strong>Revisaron</strong>{% for admin in item.reviewed_by %}<span class="reviewer reviewer--done">✓ {{ admin }}</span>{% endfor %}{% if item.reviewed_by | length == 0 %}<span class="reviewer">Nadie aún</span>{% endif %}</div>
|
|
||||||
<div><strong>Faltan</strong>{% for admin in item.missing_reviewers %}<span class="reviewer reviewer--missing">○ {{ admin }}</span>{% endfor %}{% if item.missing_reviewers | length == 0 %}<span class="reviewer reviewer--done">Completa</span>{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
<pre><code>clusterlog --json --commit review mark {{ item.entry_id }}</code></pre>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% if ops.review_queue | length == 0 %}<div class="ops-empty ops-empty--success"><strong>Cola limpia.</strong><br>No hay memorias pendientes para la versión actual de su contenido.</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ops-meeting-section" aria-labelledby="meeting-tasks-title">
|
|
||||||
<h2 id="meeting-tasks-title">Tareas abiertas para revisar</h2>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for task in ops.tasks %}
|
|
||||||
{% if task.status != "done" and task.status != "cancelled" %}
|
|
||||||
<article class="ops-list-item">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ task.task_id }}</code><span class="badge">{{ task.status }}</span><span>{{ task.actual_minutes }}/{{ task.estimate_minutes }} min</span></div>
|
|
||||||
<h3><a href="{{ task.permalink }}">{{ task.title }}</a></h3>
|
|
||||||
<p>{{ task.description }}</p>
|
|
||||||
{% if task.running_by | length > 0 %}<p><strong>En ejecución:</strong> {{ task.running_by | join(sep=", ") }}</p>{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ops-meeting-section" aria-labelledby="meeting-projects-title">
|
|
||||||
<h2 id="meeting-projects-title">Proyectos activos o bloqueados</h2>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for project in ops.projects %}
|
|
||||||
{% if project.status == "active" or project.status == "blocked" or project.status == "planned" %}
|
|
||||||
<article class="ops-list-item">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ project.project_id }}</code><span class="badge">{{ project.status }}</span>{% if project.target_date %}<span>Meta {{ project.target_date }}</span>{% endif %}</div>
|
|
||||||
<h3><a href="{{ project.permalink }}">{{ project.title }}</a></h3>
|
|
||||||
<p>{{ project.description }}</p>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header">
|
|
||||||
<p class="ops-eyebrow">BIBLIOTECA TÉCNICA</p>
|
|
||||||
<h1>{{ section.title }}</h1>
|
|
||||||
<p>{{ section.description }}</p>
|
|
||||||
<div class="ops-prose">{{ section.content | safe }}</div>
|
|
||||||
</header>
|
|
||||||
<div class="ops-list">
|
|
||||||
{% for page in section.pages %}
|
|
||||||
<article class="ops-list-item">
|
|
||||||
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div>
|
|
||||||
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
|
|
||||||
<p>{{ page.description }}</p>
|
|
||||||
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% if section.pages | length == 0 %}<p class="ops-empty">No hay páginas en esta sección.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ page.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<article class="ops-article">
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / TAREA</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
|
|
||||||
<div class="ops-meta"><span>Estado base: {{ page.extra.status }}</span><span>Prioridad: {{ page.extra.priority }}</span><span>Estimación: {{ page.extra.estimate_minutes }} min</span>{% if page.extra.due %}<span>Fecha: {{ page.extra.due }}</span>{% endif %}{% if page.extra.project_id %}<span>Proyecto: {{ page.extra.project_id }}</span>{% endif %}</div></header>
|
|
||||||
<aside class="ops-callout">El estado efectivo y el tiempo acumulado se muestran en el <a href="{{ get_url(path='@/tareas/_index.md') }}">tablero de tareas</a>; se calculan desde el registro de eventos.</aside>
|
|
||||||
<div class="ops-prose">{{ page.content | safe }}</div>
|
|
||||||
<pre><code>clusterlog --commit task start {{ page.extra.id }}
|
|
||||||
clusterlog --commit task stop {{ page.extra.id }}
|
|
||||||
clusterlog --commit task done {{ page.extra.id }}</code></pre>
|
|
||||||
</article>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block description %}{{ section.description }}{% endblock description %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow">TRABAJO COMPARTIDO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
|
|
||||||
<div class="ops-board">
|
|
||||||
{% for task in ops.tasks %}
|
|
||||||
<article class="ops-card">
|
|
||||||
<div class="ops-card__top"><code>{{ task.task_id }}</code><span class="badge badge--status">{{ task.status }}</span></div>
|
|
||||||
<h2><a href="{{ task.permalink }}">{{ task.title }}</a></h2><p>{{ task.description }}</p>
|
|
||||||
<dl><dt>Prioridad</dt><dd>{{ task.priority }}</dd><dt>Estimado</dt><dd>{{ task.estimate_minutes }} min</dd><dt>Real</dt><dd>{{ task.actual_minutes }} min</dd>{% if task.due %}<dt>Fecha</dt><dd>{{ task.due }}</dd>{% endif %}{% if task.project_id %}<dt>Proyecto</dt><dd>{{ task.project_id }}</dd>{% endif %}</dl>
|
|
||||||
{% if task.running_by | length > 0 %}<p class="ops-running">▶ En curso por {% for admin in task.running_by %}{{ admin }}{% if not loop.last %}, {% endif %}{% endfor %}</p>{% endif %}
|
|
||||||
<div class="ops-tags">{% for admin in task.assignees %}<span>+{{ admin }}</span>{% endfor %}</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% if ops.tasks | length == 0 %}<p class="ops-empty">No hay tareas.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ taxonomy.name }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow">ÍNDICE</p><h1>{{ taxonomy.name }}</h1></header>
|
|
||||||
<div class="ops-grid">
|
|
||||||
{% for term in terms %}<a class="ops-panel" href="{{ term.permalink }}"><h2>{{ term.name }}</h2><p>{{ term.page_count }} páginas</p></a>{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ term.name }} · {{ config.title }}{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<header class="ops-page-header"><p class="ops-eyebrow">{{ taxonomy.name }}</p><h1>{{ term.name }}</h1><p>{{ term.page_count }} páginas</p></header>
|
|
||||||
<div class="ops-list">{% for page in term.pages %}<article class="ops-list-item"><div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div><h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2><p>{{ page.description }}</p></article>{% endfor %}</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
base_url = "https://bitacora-cluster.example.invalid"
|
base_url = "https://clusterlog.mcps-epcm.org"
|
||||||
title = "Bitácora Operativa del Clúster"
|
title = "Bitácora Operativa del Clúster"
|
||||||
description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte."
|
description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte."
|
||||||
default_language = "es"
|
default_language = "es"
|
||||||
|
|||||||
Reference in New Issue
Block a user