Author SHA1 Message Date
devopsandClaude Sonnet 5 0ffa1ff15c Merge branch 'fix/tmux-host-remote-control'
Lanza claude --remote-control al recrear la sesion tmux-host para que
quede visible en claude.ai sin intervencion manual.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 15:16:58 +00:00
devopsandClaude Sonnet 5 f48860fcd8 fix(tmux-host): lanzar claude con --remote-control al recrear la sesion
Sin la flag, la sesion de tmux-host nunca queda visible como
controlable remotamente en claude.ai: remoteEnabled quedaba en false
porque nadie la pedia. Confirmado en vivo que --remote-control si
empareja (aparecio como sesion activa "zimaos-quizzical") mientras el
proceso se mantiene vivo.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 15:16:00 +00:00
devopsandClaude Sonnet 5 955eec6e75 fix(tmux-host): lanzar claude al recrear la sesion + backoff de arranque
check_tmux_session() recreaba la sesion tmux "lab" tras un reinicio del
NAS pero nunca lanzaba `claude` adentro -- quedaba un shell vacio hasta
que alguien entraba a mano. Confirmado en vivo tras un corte de luz real
(2026-09-19): la sesion SI se recreaba sola, pero Claude Code no.

Se agrega wait_for_workdir() con backoff (hasta 120s por defecto) antes
de tocar el log o crear la sesion, para el caso de un reinicio completo
del host donde /media/HDD-Storage puede tardar en montarse mas de lo
que tarda Docker en arrancar este contenedor -- antes eso fallaba en
silencio y ese arranque no dejaba rastro en el log.

Probado en un contenedor descartable aparte (tmux-host-test): stop+start
desde cero recreo la sesion con claude corriendo, sin intervencion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 20:15:37 +00:00
devopsandClaude Sonnet 5 9d04c73b20 feat: bumpear APP_VERSION al promover a producción
Complementa el cambio en apps-registry (footer lee process.env.APP_VERSION
para mostrar la versión real desplegada) -- sin esto, promover a
Producción actualizaría la imagen pero dejaría el footer mostrando la
versión vieja.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 04:04:17 +00:00
devopsandClaude Sonnet 5 3640cd5754 feat: script de promoción manual de Staging a Producción
Contraparte del cambio en apps-registry/.gitea/workflows/build.yaml:
desde ahora el CI solo actualiza Staging automáticamente en cada push.
Este script lee la versión actual en
workloads/ecommerce-staging/frontend/patches/deployment.yaml y la
aplica a workloads/ecommerce/frontend/patches/deployment.yaml (con
confirmación interactiva), commiteando y pusheando a apps-registry.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 02:46:06 +00:00
devopsandClaude Sonnet 5 78b97bdd59 feat(tmux-host): Dockerfile con WORKDIR fijo + healthcheck cada 30 min
Reemplaza el docker run inline (alpine sh -c "apk add ...") por una
imagen construida desde scripts/tmux-host/Dockerfile, con WORKDIR en la
raiz del lab para que docker exec y lab-session.sh ya arranquen
posicionados ahi sin cd manual.

Agrega scripts/tmux-host/healthcheck.sh (via entrypoint.sh, background
del propio contenedor): cada 30 min chequea conectividad real
(curl a gitea.cruzcloud.net) y la existencia de la sesion tmux "lab".
La red solo se loguea+notifica por Telegram (nunca se intenta corregir
desde el contenedor); la sesion tmux si se recrea sola si falta. Log
persistente en /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log.
Reusa el bot/chat_id de Telegram ya configurado en scripts/lab-agent/.

Validado en un contenedor descartable (tmux-host-test, intervalo de 60s):
confirmado WORKDIR correcto via docker exec, creacion inicial de la
sesion y recreacion automatica tras un kill-session manual.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012xyPak6HWFmBCqXrvFsejL
2026-09-06 17:16:41 +00:00
devops 40cb058fd4 Merge fix/lab-session-tmux-persistente into main
Merge local directo (sin PR de Gitea) por acuerdo del usuario; ver
feedback_git_fix_branches en memoria del proyecto para el porque de
este flujo estandar.
2026-08-27 22:18:47 -05:00
devops 0bf571cb93 feat: agrega lab-session.sh para reconectar a la sesion tmux persistente
Punto de entrada unico para adjuntarse (o crear si no existe) a la
sesion "lab" dentro del contenedor tmux-host, donde corre Claude Code
de forma persistente ante cortes de SSH/cambios de dispositivo.
2026-08-27 22:12:46 -05:00
devops acf7521990 Merge fix/adr0006-meilisearch-secrets into main 2026-08-26 15:00:07 -05:00
devops 9c0a111f82 create-commerce-secrets.sh: agregar MEILI_MASTER_KEY/MEILISEARCH_API_KEY
Fase 1 de la Etapa 2 del ADR 0006 (apps-registry): el StatefulSet de
Meilisearch lee MEILI_MASTER_KEY del secret commerce-secrets, y el
plugin de Medusa leera MEILISEARCH_API_KEY con el mismo valor (Fase 2,
distinto nombre de env var por cada lado, mismo secret).

Nota operativa: este script regenera el secret completo en cada
corrida (JWT_SECRET/COOKIE_SECRET/REVALIDATE_SECRET siempre nuevos).
Para aplicar SOLO estas dos claves nuevas al secret ya en uso en el
cluster real, no correr el script completo -- rotaria credenciales de
postgres/redis/minio ya activas. Se aplico via patch puntual al Secret
en vivo, fuera de este script, para esta vez.
2026-08-26 14:59:52 -05:00
devops 1f25869c4f Merge fix/lab-agent-follow-redirects into main 2026-08-26 13:33:26 -05:00
devops cef3dd3a28 lab-agent: follow redirects on public domain health checks
commerce.cruzcloud.net's root path now 301s to /app (Medusa admin) via
a Traefik Middleware — without -L the check reported a false 404 on an
otherwise healthy service. Generic fix (curl -L) instead of
hardcoding a per-domain path, since routing now owns the redirect.
2026-08-26 13:29:34 -05:00
devops 25276ad5b1 Merge branch 'fix/lab-agent-telegram-horario' into main
Notificaciones vía Telegram (reemplaza ntfy.sh), pausa nocturna
(06:00-23:00 hora local, con doble verificación script+systemd),
timer de systemd activado (cada 60 min, bajado de 15 para reducir
costo), formato con íconos + notificación silenciosa para info +
respuestas en hilo al recuperarse + log adjunto. Incluye el fix de
Environment=HOME= para systemd --user (el manager de devops arranca
con HOME=/DATA, no con el override de shell interactiva). Todo
validado en vivo antes de activar.
2026-08-26 00:29:02 -05:00
devops 09048dd772 Bajar frecuencia del timer a 60 min para reducir costo
OnCalendar cada 60 min en vez de 15 (06:00-22:00 hora local, 17
corridas/día en vez de 68). Aplicado en vivo: unidad actualizada,
timer reiniciado, próxima corrida confirmada vía
systemctl --user list-timers.
2026-08-26 00:27:53 -05:00
devops a2717e922a Activar timer: fix HOME= en la unidad, instrucciones de instalación
El manager de systemd --user de devops arranca con HOME=/DATA (el de
/etc/passwd), no /DATA/AppData/home-devops -- ese override solo se
aplica en una shell interactiva vía /etc/profile.d/zz-devops-home.sh,
que el manager de systemd nunca carga. Sin Environment=HOME=... en
lab-agent.service, claude -p habría buscado las credenciales en
/DATA/.claude/ (no existe) y fallado en cada corrida. Detectado y
corregido en vivo corriendo el servicio manualmente vía
systemctl --user start antes de habilitar el timer.

README documenta la instalación real: unidades en
/DATA/.config/systemd/user/ (no ~/.config/... de una shell
interactiva), loginctl enable-linger devops como único paso con sudo.

Timer activo desde 2026-08-26 (lab-agent.timer, cada 15 min,
06:00-23:00 hora local), tras confirmación explícita y ver el costo
mensual estimado.
2026-08-26 00:25:45 -05:00
devops cd163e6b7c Telegram: notificación silenciosa para info y respuestas en hilo
log() pasa a escribir en stderr (transparente, sigue yendo al mismo
RUN_LOG vía el "exec > tee 2>&1"): necesario para que notify_send
pueda devolver el message_id por stdout sin que un log() interno se
cuele en la captura -- mismo patrón de bug que ya se había corregido
en execute_corrective_action.

notify_send ahora acepta un reply_to_message_id opcional y manda
disable_notification=true para nivel "info" (ruido de rutina no debe
sonar; fallas/acciones/alertas de seguridad sí). Cuando ejecuta una
acción correctiva, el message_id enviado se guarda por recurso en
state.json (attach_telegram_message_id); si ese recurso se recupera
solo en una corrida posterior, compute_eligibility manda un aviso
silencioso como respuesta al mensaje original antes de limpiar el
estado -- agrupa visualmente todo un incidente en un hilo.

Validado en vivo con el canario desechable: acción real -> message_id
capturado en state.json -> parcheado a imagen sana -> reply en hilo
confirmado al recuperarse, silencioso.
2026-08-26 00:21:02 -05:00
devops c273056771 Notificaciones vía Telegram, pausa nocturna y unidades systemd
Reemplaza ntfy.sh por Telegram Bot API (preferencia del usuario, no
reduce el costo — el costo real es de la API de Claude/Sonnet, no del
canal). Texto plano sin parse_mode: el motivo que devuelve Claude es
texto libre y puede traer caracteres que rompen el parser Markdown de
Telegram. Token en TELEGRAM_BOT_TOKEN_FILE (dueño devops, no root-only
-- el servicio corre como devops por el login de suscripción, mismo
criterio ya aplicado a GITEA_TOKEN_FILE). Validado en vivo: entrega
confirmada con el mismo código de notify_send().

Agrega pausa nocturna (ACTIVE_HOURS_START/END, default 06:00-23:00
hora local) para no acumular costo de API sin necesidad: el script
chequea el horario ANTES de tocar kubectl/docker/curl o invocar
claude -p, no solo antes de notificar. Nota documentada: esta ventana
no se solapa con COMMERCE_BACKEND_WINDOW_* (00:00-06:00), por lo que
medusa-deploy queda efectivamente inalcanzable para auto-restart en la
práctica -- pendiente de decisión aparte.

Agrega unidades systemd de usuario (lab-agent/systemd/*.service,
*.timer) con límites de CPU/memoria: OnCalendar cada 15 min dentro del
horario activo, corre como devops (systemctl --user), sin habilitar
todavía -- requiere confirmación explícita tras ver el costo mensual
estimado (~US$60-65, documentado en agente-monitoreo.md).
2026-08-26 00:00:56 -05:00
devops 61a951f576 Merge branch 'fix/lab-agent-diagnostico-headless' into main
Agente headless de diagnóstico/corrección del lab (lab-agent), con
lista blanca aprobada, decisión de Claude sin acceso a herramientas
(claude -p --allowedTools "" + json-schema, el wrapper ejecuta y
revalida), y prueba end-to-end validada en vivo 2026-08-26.
2026-08-25 23:15:37 -05:00
11 changed files with 575 additions and 48 deletions

No files matched your search

+8
View File
@@ -32,6 +32,12 @@ JWT_SECRET="$(random_secret)"
COOKIE_SECRET="$(random_secret)"
REVALIDATE_SECRET="$(random_secret)"
# Mismo valor para las dos claves: MEILI_MASTER_KEY la lee el propio
# contenedor getmeili/meilisearch (servidor), MEILISEARCH_API_KEY la lee
# el plugin @rokmohar/medusa-plugin-meilisearch en Medusa (cliente) --
# cada lado usa su propia convencion de nombre de env var.
MEILI_MASTER_KEY="$(random_secret)"
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres-svc:5432/${POSTGRES_DB}?sslmode=disable"
REDIS_URL="redis://:${REDIS_PASSWORD}@redis-svc:6379"
@@ -49,6 +55,8 @@ kubectl -n "${NAMESPACE}" create secret generic commerce-secrets \
--from-literal=JWT_SECRET="${JWT_SECRET}" \
--from-literal=COOKIE_SECRET="${COOKIE_SECRET}" \
--from-literal=REVALIDATE_SECRET="${REVALIDATE_SECRET}" \
--from-literal=MEILI_MASTER_KEY="${MEILI_MASTER_KEY}" \
--from-literal=MEILISEARCH_API_KEY="${MEILI_MASTER_KEY}" \
--dry-run=client \
-o yaml |
kubectl apply -f -
+59 -9
View File
@@ -7,11 +7,14 @@ acción correctiva de bajo riesgo. Ver la lista blanca completa, la
justificación de gobernanza y la evidencia de la prueba end-to-end en
`apps-registry` → `docs/arquitectura/agente-monitoreo.md`.
**Estado (2026-08-26): script y prueba end-to-end validados en vivo**
contra el cluster real (deployment canario desechable, ciclo completo
DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de
systemd (Paso 4) **todavía no está activo** — pendiente de tu
confirmación explícita.
**Estado (2026-08-26): timer de systemd ACTIVO** (`lab-agent.timer`,
usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de
15 a 60 min el 2026-08-26 para reducir costo). Validado
en vivo contra el cluster real: DRY_RUN → ejecución real → rate
limit/bloqueo → recuperación con hilo en Telegram → limpieza, y
corrida manual vía `systemctl --user start` con el entorno real del
timer antes de habilitarlo. Ver `docs/arquitectura/agente-monitoreo.md`
para la evidencia completa y el costo mensual estimado.
## Diseño de seguridad (por qué Claude no ejecuta comandos directamente)
@@ -118,6 +121,30 @@ Si no existe, el agente sigue funcionando pero pierde la señal de
"runner atascado" vía API de Gitea Actions (solo detecta el contenedor
caído/reiniciando).
**Notificaciones (Telegram, reemplaza a ntfy.sh desde 2026-08-26):**
creá el bot con [@BotFather](https://t.me/BotFather) (`/newbot`) y
obtené tu `chat_id` (mandale un mensaje al bot primero, después
`https://api.telegram.org/bot<TOKEN>/getUpdates`). El token va en un
archivo aparte, dueño `devops` — **no** root-only pese al patrón
original: el servicio corre como `devops` (necesita su login de
suscripción para `claude -p`), así que un archivo `root:root 600` le
sería ilegible, mismo criterio ya aplicado a `GITEA_TOKEN_FILE`.
```bash
mkdir -p "$(dirname "$TELEGRAM_BOT_TOKEN_FILE")"
echo "<tu-token-de-botfather>" > "$TELEGRAM_BOT_TOKEN_FILE"
chmod 600 "$TELEGRAM_BOT_TOKEN_FILE"
# y en lab-agent.env: TELEGRAM_CHAT_ID=<tu-chat-id> (no es secreto)
```
**Horario activo:** `ACTIVE_HOURS_START`/`ACTIVE_HOURS_END` (hora local,
default `06:00`–`23:00`). Fuera de esa ventana el script sale antes de
tocar kubectl/docker/curl o invocar `claude -p` — no gasta nada, no
solo omite la notificación. **Ojo:** esta ventana no se solapa con
`COMMERCE_BACKEND_WINDOW_*` (`00:00`–`06:00`) — mientras el agente está
pausado, `medusa-deploy` nunca es elegible para auto-restart en la
práctica. Ver `docs/arquitectura/agente-monitoreo.md`.
## Probar
```bash
@@ -132,8 +159,31 @@ ejecuta de verdad (confirmado con `kubectl get events` y la anotación
`restartedAt`), y una segunda corrida sobre el mismo recurso caído
respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se
encontraron y corrigieron en esa prueba — ver el detalle en
`docs/arquitectura/agente-monitoreo.md`.
`docs/arquitectura/agente-monitoreo.md`. Telegram (silencioso para
`info`, hilo de respuestas al recuperarse) también se validó en vivo
con el mismo canario.
**No actives el timer de systemd (Paso 4) todavía** — requiere tu
confirmación explícita por separado, aunque la prueba end-to-end ya
pasó.
## Instalar el timer de systemd
Unidades de **usuario** (`systemctl --user`), corren como `devops` —
necesario porque `claude -p` usa su login de suscripción. Ojo: el
manager de `systemd --user` de `devops` arranca con `HOME=/DATA` (el
de `/etc/passwd`), no `/DATA/AppData/home-devops` — por eso
`lab-agent.service` fija `Environment=HOME=...` explícitamente; sin
eso, `claude -p` no encuentra las credenciales. Los archivos van en
`/DATA/.config/systemd/user/`, no en el `~/.config/...` de una shell
interactiva (que resuelve a otra ruta por el mismo motivo).
```bash
mkdir -p /DATA/.config/systemd/user
cp systemd/lab-agent.service systemd/lab-agent.timer /DATA/.config/systemd/user/
sudo loginctl enable-linger devops # una vez — si no, systemctl --user ni conecta
export XDG_RUNTIME_DIR=/run/user/$(id -u devops)
systemctl --user daemon-reload
systemctl --user start lab-agent.service # probar una corrida manual primero
systemctl --user enable --now lab-agent.timer
systemctl --user list-timers lab-agent.timer
```
Activo desde 2026-08-26. Ver el costo mensual estimado y el detalle
completo en `docs/arquitectura/agente-monitoreo.md`.
+19 -4
View File
@@ -62,10 +62,25 @@ GITEA_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token
CLAUDE_MODEL=claude-sonnet-5
CLAUDE_MAX_BUDGET_USD=0.50
# --- Notificaciones (Paso 3 — dejar vacío hasta tener un canal real) ---
# Ej. self-hosted: https://ntfy.cruzcloud.net/lab-agent
# ntfy.sh público: https://ntfy.sh/<topic-privado-dificil-de-adivinar>
NTFY_URL=
# --- Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26) ---
# Token del bot: creado con @BotFather, va en un archivo aparte (ver
# README.md). NO root-only pese al patrón original — el servicio corre
# como `devops` (necesita el login de suscripción de ese usuario para
# claude -p), así que un archivo root:root 600 sería ilegible para él.
# Mismo criterio ya aplicado a GITEA_TOKEN_FILE.
TELEGRAM_BOT_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/telegram_bot_token
# chat_id NO es secreto (no autentica nada sin el token) — va acá directo.
TELEGRAM_CHAT_ID=
# --- Horario activo (hora LOCAL del host) ---
# Fuera de esta ventana el script aborta ANTES de tocar kubectl/docker/
# curl o invocar claude -p — pausa nocturna para no acumular costo de
# API. OJO: esta ventana NO se solapa con COMMERCE_BACKEND_WINDOW_*
# (00:00-06:00) — mientras el agente está pausado, medusa-deploy nunca
# es elegible para auto-restart en la práctica, aunque la regla siga
# activa en el código. Ver docs/arquitectura/agente-monitoreo.md.
ACTIVE_HOURS_START=06:00
ACTIVE_HOURS_END=23:00
# --- Pruebas controladas (Paso 5) ---
# DRY_RUN=1: calcula elegibilidad y decisión igual que en producción,
+172 -35
View File
@@ -51,7 +51,18 @@ source "$ENV_FILE"
: "${GITEA_TOKEN_FILE:=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token}"
: "${CLAUDE_MODEL:=claude-sonnet-5}"
: "${CLAUDE_MAX_BUDGET_USD:=0.50}"
: "${NTFY_URL:=}"
# Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26).
# TELEGRAM_BOT_TOKEN_FILE: secreto, NO versionado. TELEGRAM_CHAT_ID no es
# secreto (solo identifica a quién mandar, no autentica nada por sí solo).
: "${TELEGRAM_BOT_TOKEN_FILE:=}"
: "${TELEGRAM_CHAT_ID:=}"
# Horario activo (hora LOCAL del host): fuera de esta ventana el script
# aborta ANTES de tocar kubectl/docker/curl o invocar claude -p — pausa
# nocturna para no acumular costo de API sin necesidad. Ver
# docs/arquitectura/agente-monitoreo.md para el tradeoff con la ventana
# de medusa-deploy (00:00-06:00).
: "${ACTIVE_HOURS_START:=06:00}"
: "${ACTIVE_HOURS_END:=23:00}"
: "${DRY_RUN:=1}"
for arg in "$@"; do
@@ -71,12 +82,36 @@ ACTIONS_LOG="${LOG_DIR}/actions.jsonl"
exec > >(tee -a "$RUN_LOG") 2>&1
iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
log() { echo "[$(iso_now)] $*"; }
log() { echo "[$(iso_now)] $*" >&2; } # stderr a propósito: el stdout de una función
# queda limpio para $(...) (ej. capturar un
# message_id de Telegram) sin que un log()
# interno se cuele en la captura. Va al mismo
# RUN_LOG igual, por el "exec > tee 2>&1" de arriba.
trap 'log "ERROR inesperado en línea $LINENO (comando: $BASH_COMMAND)"' ERR
log "=== lab-agent inicio (DRY_RUN=${DRY_RUN}) ==="
in_time_window() { # start end (HH:MM, hora local) -> 0 si "ahora" cae adentro
local start="$1" end="$2" now_hm
now_hm="$(date +%H:%M)"
if [[ "$start" < "$end" ]]; then
[[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]]
else
[[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]]
fi
}
# Pausa nocturna: si estamos fuera del horario activo, salir ANTES de tocar
# kubectl/docker/curl o invocar claude -p — ni siquiera se gasta el costo
# de diagnóstico, no solo el de la llamada a claude. Sin notificación (sería
# spam cada 15 min durante toda la pausa) — solo queda en el log del run.
if ! in_time_window "$ACTIVE_HOURS_START" "$ACTIVE_HOURS_END"; then
log "fuera de horario activo (${ACTIVE_HOURS_START}-${ACTIVE_HOURS_END} hora local) — se omite esta corrida sin costo de API"
log "=== lab-agent fin (omitido por horario) ==="
exit 0
fi
for c in "$KUBECTL_BIN" docker curl jq flock "$CLAUDE_BIN" date; do
command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; }
done
@@ -127,6 +162,14 @@ mark_action_taken() { # resource -> preserva first_unhealthy_at, marca last_acti
state_write "$resource" "$new_state"
}
attach_telegram_message_id() { # resource message_id -> guarda el id para poder responder en hilo cuando se recupere
local resource="$1" msg_id="$2" prev new_state
[[ -z "$msg_id" ]] && return 0
prev="$(state_read "$resource")"
new_state="$(jq -nc --argjson prev "$prev" --arg mid "$msg_id" '$prev + {telegram_message_id: ($mid | tonumber)}')"
state_write "$resource" "$new_state"
}
log_action() { # resource decision result reason
local resource="$1" decision="$2" result="$3" reason="$4"
jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \
@@ -139,15 +182,74 @@ log_action() { # resource decision result reason
# Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado)
# ---------------------------------------------------------------------------
notify_send() { # title message
local title="$1" message="$2"
if [[ -z "$NTFY_URL" ]]; then
log "NOTIFY (sin canal configurado aún — Paso 3) [$title]"
notify_level_icon() { # level -> emoji
case "$1" in
error) echo "❌" ;;
security) echo "🚨" ;;
action_ok) echo "✅" ;;
action_fail) echo "🛑" ;;
info) echo "👀" ;;
*) echo "🤖" ;;
esac
}
notify_send() { # level title message [reply_to_message_id] -> imprime el message_id enviado (stdout), o nada si falló/no configurado
local level="$1" title="$2" message="$3" reply_to="${4:-}" icon
icon="$(notify_level_icon "$level")"
if [[ ! -r "$TELEGRAM_BOT_TOKEN_FILE" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [${icon} ${title}]"
log "$message"
return 0
fi
if ! curl -s -m 10 -H "Title: ${title}" -d "$message" "$NTFY_URL" >/dev/null 2>&1; then
log "WARN: fallo al enviar notificación a NTFY_URL"
local token text resp silent="false"
# Silenciosa (sin sonido/vibración) solo para "info" — ruido de rutina
# (nada elegible, todo sano) no debería interrumpir; fallas/acciones/
# alertas de seguridad sí hacen sonido.
[[ "$level" == "info" ]] && silent="true"
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
# Texto plano, sin parse_mode: el "reason" de claude es texto libre y
# puede traer _, *, [, ] etc. que rompen el parser Markdown de Telegram
# (400 Bad Request) — más vale entrega confiable que formato. Los
# emojis andan bien en texto plano, no dependen de parse_mode.
text="🤖 lab-agent
${icon} ${title}
──────────────
${message}"
text="${text:0:4000}"
local curl_args=(-s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage"
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}"
--data-urlencode "text=${text}"
--data-urlencode "disable_notification=${silent}")
[[ -n "$reply_to" ]] && curl_args+=(--data-urlencode "reply_to_message_id=${reply_to}")
resp="$(curl "${curl_args[@]}" 2>&1)"
token=""
if [[ "$resp" == *'"ok":true'* ]]; then
jq -r '.result.message_id // empty' <<<"$resp" 2>/dev/null
else
log "WARN: fallo al enviar notificación a Telegram: ${resp}"
fi
}
notify_attach_log() { # caption -> adjunta RUN_LOG como documento (best-effort)
local caption="$1"
[[ -r "$TELEGRAM_BOT_TOKEN_FILE" && -n "$TELEGRAM_CHAT_ID" ]] || return 0
if [[ ! -r "$RUN_LOG" ]]; then
log "WARN: no se pudo adjuntar el log a Telegram, no es legible: ${RUN_LOG}"
return 0
fi
local token resp
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
# sendDocument, no sendMessage: el log completo va como archivo, no como
# texto. Nota: al momento de subirlo el archivo todavía se está
# escribiendo (exec > tee), así que le faltan sus últimas 2-3 líneas
# (el cierre "=== lab-agent fin ===") — cosmético, no afecta el resto.
resp="$(curl -s -m 30 -X POST "https://api.telegram.org/bot${token}/sendDocument" \
-F "chat_id=${TELEGRAM_CHAT_ID}" \
-F "caption=${caption:0:1000}" \
-F "document=@${RUN_LOG}" 2>&1)"
token=""
if [[ "$resp" != *'"ok":true'* ]]; then
log "WARN: fallo al adjuntar el log a Telegram: ${resp}"
fi
}
@@ -161,15 +263,7 @@ DEPLOY_JSON="$("$KUBECTL_BIN" -n "$NAMESPACE" get deployment -o json 2>/dev/null
mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON")
in_commerce_backend_window() {
local now_hm start end
now_hm="$(date +%H:%M)"
start="$COMMERCE_BACKEND_WINDOW_START"
end="$COMMERCE_BACKEND_WINDOW_END"
if [[ "$start" < "$end" ]]; then
[[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]]
else
[[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]]
fi
in_time_window "$COMMERCE_BACKEND_WINDOW_START" "$COMMERCE_BACKEND_WINDOW_END"
}
# devuelve: "eligible" | "not_eligible:<motivo>"
@@ -183,7 +277,20 @@ compute_eligibility() { # resource_key is_unhealthy window_ok
blocked="$(jq -r '.blocked // false' <<<"$state")"
if [[ "$is_unhealthy" != "1" ]]; then
[[ -n "$first_unhealthy_at" ]] && state_clear "$resource_key"
if [[ -n "$first_unhealthy_at" ]]; then
local prev_msg_id
prev_msg_id="$(jq -r '.telegram_message_id // empty' <<<"$state")"
# Si hubo una acción notificada sobre este recurso y ahora se
# recuperó solo, avisar como respuesta al mensaje original (hilo).
# >/dev/null a propósito: no necesitamos el message_id de ESTE aviso,
# y notify_send también se usa en otros lados vía $(...) -- no
# mezclar su stdout con el "not_eligible:healthy" que devuelve esta
# función.
if [[ -n "$prev_msg_id" ]]; then
notify_send "info" "Recuperado" "✅ ${resource_key} volvió a estar sano por su cuenta." "$prev_msg_id" >/dev/null
fi
state_clear "$resource_key"
fi
echo "not_eligible:healthy"
return
fi
@@ -342,7 +449,11 @@ CLUSTER_WIDE_SNAPSHOT="$("$KUBECTL_BIN" get pods -A -o custom-columns='NS:.metad
PUBLIC_CHECKS=""
for d in $PUBLIC_DOMAINS; do
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://${d}/" 2>/dev/null || echo "000")"
# -L: sigue redirects (ej. commerce.cruzcloud.net/ -> /app vía Middleware
# de Traefik) para reportar el estado final real, sin tener que
# hardcodear una ruta distinta por dominio en este script — el routing
# decide el path, no este check.
code="$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 "https://${d}/" 2>/dev/null || echo "000")"
PUBLIC_CHECKS="${PUBLIC_CHECKS}${d} -> HTTP ${code}\n"
log "curl https://${d}/ -> HTTP ${code}"
done
@@ -443,7 +554,7 @@ CLAUDE_AUTH_ENV=()
if [[ -n "$ANTHROPIC_API_KEY_FILE" ]]; then
if [[ ! -r "$ANTHROPIC_API_KEY_FILE" ]]; then
log "FATAL: ANTHROPIC_API_KEY_FILE está configurado (${ANTHROPIC_API_KEY_FILE}) pero no se puede leer"
notify_send "lab-agent: error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
notify_send error "Error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
exit 1
fi
CLAUDE_AUTH_ENV=("ANTHROPIC_API_KEY=$(<"$ANTHROPIC_API_KEY_FILE")")
@@ -465,7 +576,8 @@ CLAUDE_JSON="$(
2>>"$RUN_LOG"
)" || {
log "FATAL: la invocación a claude -p falló"
notify_send "lab-agent: error" "claude -p terminó con error, ver ${RUN_LOG}"
notify_send error "claude -p falló" "La invocación terminó con error, ver log adjunto"
notify_attach_log "❌ claude -p falló"
exit 1
}
@@ -476,19 +588,21 @@ IS_ERROR="$(jq -r '.is_error // false' <<<"$CLAUDE_JSON")"
PERMISSION_DENIALS="$(jq -c '.permission_denials // []' <<<"$CLAUDE_JSON")"
if [[ "$PERMISSION_DENIALS" != "[]" ]]; then
log "ALERTA: claude intentó usar herramientas pese a --allowedTools \"\": ${PERMISSION_DENIALS}"
notify_send "lab-agent: ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas: ${PERMISSION_DENIALS}"
notify_send security "ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas pese a --allowedTools \"\": ${PERMISSION_DENIALS}"
fi
if [[ "$IS_ERROR" == "true" ]]; then
log "FATAL: claude -p devolvió is_error=true"
notify_send "lab-agent: error" "claude -p devolvió un error, ver ${RUN_LOG}"
notify_send error "claude -p devolvió error" "is_error=true, ver log adjunto"
notify_attach_log "❌ claude -p devolvió error"
exit 1
fi
DECISION_JSON="$(jq -r '.structured_output // empty' <<<"$CLAUDE_JSON")"
if [[ -z "$DECISION_JSON" ]]; then
log "FATAL: no se recibió structured_output válido"
notify_send "lab-agent: error" "Respuesta sin structured_output, ver ${RUN_LOG}"
notify_send error "Respuesta sin structured_output" "claude -p no devolvió salida estructurada válida, ver log adjunto"
notify_attach_log "❌ Respuesta sin structured_output"
exit 1
fi
@@ -552,6 +666,8 @@ execute_corrective_action() { # resource -> exit 0=ok/1=fail, deja LAST_ACTION_*
NOTIFY_LINES=()
ACTIONS_TAKEN=0
ACTIONS_FAILED=0
ACTED_RESOURCES=() # recursos con decision=="restart" este ciclo -> para hilo de Telegram
while IFS= read -r action_json; do
[[ -z "$action_json" ]] && continue
@@ -564,13 +680,15 @@ while IFS= read -r action_json; do
if [[ "$decision" == "restart" && "$eligibility" != "eligible" ]]; then
log "ANOMALÍA: claude propuso restart en '${resource}' pero el wrapper lo marca '${eligibility}' — RECHAZADO, no se ejecuta"
log_action "$resource" "restart_rejected" "rejected_not_eligible" "claude_reason=${claude_reason} | wrapper_eligibility=${eligibility}"
NOTIFY_LINES+=("[RECHAZADO] ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}")
ACTIONS_FAILED=1
NOTIFY_LINES+=("🛑 Rechazado — ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}")
continue
fi
if [[ "$decision" == "restart" ]]; then
if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi
ACTIONS_TAKEN=1
ACTED_RESOURCES+=("$resource")
# Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un
# solo intento automático por incidente, sin reintentos silenciosos.
@@ -583,17 +701,20 @@ while IFS= read -r action_json; do
if [[ "$result" == "ok" ]]; then
log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK"
log_action "$resource" "restart" "success" "$claude_reason"
NOTIFY_LINES+=("[ACCIÓN OK] ${resource}: ${LAST_ACTION_CMD}. Motivo: ${claude_reason}")
NOTIFY_LINES+=("✅ Acción ejecutada — ${resource}: ${LAST_ACTION_CMD}
Motivo: ${claude_reason}")
else
log "ACCIÓN FALLIDA: ${LAST_ACTION_CMD:-N/A} -> ${LAST_ACTION_STDERR}"
log_action "$resource" "restart" "failure" "$claude_reason | stderr=${LAST_ACTION_STDERR}"
NOTIFY_LINES+=("[ACCIÓN FALLÓ] ${resource}: ${LAST_ACTION_STDERR}. Queda bloqueado hasta que un humano lo revise (no se reintentará solo).")
ACTIONS_FAILED=1
NOTIFY_LINES+=("🛑 Acción falló — ${resource}: ${LAST_ACTION_STDERR}
Queda bloqueado hasta revisión humana, no se reintenta solo.")
fi
else
log "notify_only: ${resource} — ${claude_reason}"
log_action "$resource" "notify_only" "n/a" "$claude_reason"
if [[ "$eligibility" != "not_eligible:healthy" ]]; then
NOTIFY_LINES+=("[SOLO DIAGNÓSTICO] ${resource} (${eligibility}): ${claude_reason}")
NOTIFY_LINES+=("👀 Diagnóstico — ${resource} (${eligibility}): ${claude_reason}")
fi
fi
done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
@@ -603,13 +724,29 @@ done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
# ---------------------------------------------------------------------------
if [[ "${#NOTIFY_LINES[@]}" -gt 0 || "$ACTIONS_TAKEN" == "1" ]]; then
MSG="Resumen: ${CLAUDE_SUMMARY}"$'\n\n'
for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n'; done
MSG="${MSG}"$'\n'"log completo: ${RUN_LOG}"
title="lab-agent"
[[ "$ACTIONS_TAKEN" == "1" ]] && title="lab-agent: acción correctiva ejecutada"
[[ "$DRY_RUN" == "1" ]] && title="[DRY_RUN] ${title}"
notify_send "$title" "$MSG"
MSG="📋 Resumen: ${CLAUDE_SUMMARY}"$'\n\n'
for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n\n'; done
MSG="${MSG}📄 log completo adjunto abajo"
if [[ "$ACTIONS_FAILED" == "1" ]]; then
level="action_fail"; title="Acción falló o fue rechazada"
elif [[ "$ACTIONS_TAKEN" == "1" ]]; then
level="action_ok"; title="Acción correctiva ejecutada"
else
level="info"; title="Novedades del ciclo"
fi
[[ "$DRY_RUN" == "1" ]] && title="🧪 [DRY_RUN] ${title}"
SENT_MSG_ID="$(notify_send "$level" "$title" "$MSG")"
notify_attach_log "$(notify_level_icon "$level") ${title}"
# Guardar el message_id contra cada recurso accionado este ciclo, para
# poder responder en hilo el día que se recupere solo (ver
# compute_eligibility). No aplica en DRY_RUN, ya que no se toca el
# estado real en modo simulación.
if [[ -n "$SENT_MSG_ID" && "$DRY_RUN" != "1" ]]; then
for r in "${ACTED_RESOURCES[@]}"; do
attach_telegram_message_id "$r" "$SENT_MSG_ID"
done
fi
else
log "Sin anomalías ni acciones este ciclo — no se notifica."
fi
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=lab-agent: diagnostico/correccion del lab GitOps cruzcloud
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent
# El manager de systemd --user de devops arranca con HOME=/DATA (el de
# /etc/passwd), no /DATA/AppData/home-devops -- ese override solo se
# aplica al abrir una shell interactiva via /etc/profile.d/zz-devops-home.sh,
# que el manager de systemd nunca carga. Sin esto, claude -p buscaria
# ~/.claude/.credentials.json en /DATA/.claude/ (no existe) y fallaria
# la autenticacion. Confirmado en vivo 2026-08-26.
Environment=HOME=/DATA/AppData/home-devops
ExecStart=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent/lab-agent.sh --live
TimeoutStartSec=300
# Limites de recursos (pedido explicito de gobernanza, nunca irrestricto).
# El propio script casi siempre corta antes por la pausa nocturna o por
# no tener nada elegible, asi que en la practica rara vez se acerca a
# estos topes -- estan para contener un caso patologico (claude -p
# colgado, kubectl en loop), no para el uso normal.
CPUQuota=100%
MemoryMax=1G
MemorySwapMax=256M
Nice=10
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Dispara lab-agent.service cada 60 min, 06:00-23:00 hora local
[Timer]
# Horas 06..22, minuto :00 -> ultima corrida real 22:00 (17 corridas/dia).
# El propio script vuelve a chequear ACTIVE_HOURS_START/END al arrancar
# (defensa en profundidad si esta unidad se edita a mano sin tocar el
# .env, o si corre manualmente fuera de horario).
OnCalendar=*-*-* 06..22:00:00
AccuracySec=30s
Persistent=false
[Install]
WantedBy=timers.target
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Reconecta a la sesion tmux persistente "lab" dentro del contenedor
# tmux-host. Si la sesion no existe (p.ej. el contenedor se acaba de
# recrear), la crea. El comando es siempre el mismo sin importar el
# estado previo.
#
# Ver docs-portal: arquitectura/sesion-tmux-persistente.md
CONTAINER="tmux-host"
SESSION="lab"
# Repo raiz del lab (CLAUDE.md, apps-registry/, platform-infra/, scripts/).
# Mismo path que el WORKDIR de scripts/tmux-host/Dockerfile -- pasado
# explicito acá (-c) para no depender de que docker exec herede el WORKDIR
# de la imagen.
WORKDIR="/DATA/AppData/k3d-lab-v3/lab-cruzcloud"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "El contenedor '$CONTAINER' no esta corriendo. Verifica con 'docker ps -a'." >&2
exit 1
fi
docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
docker exec -it "$CONTAINER" tmux new -s "$SESSION" -c "$WORKDIR"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Promueve el tag de imagen del frontend actualmente desplegado en Staging
# (workloads/ecommerce-staging/frontend/patches/deployment.yaml) al
# manifiesto real de Producción (workloads/ecommerce/frontend/patches/
# deployment.yaml). Es la contraparte manual del job "Actualizar
# Manifiesto GitOps (CD)" de .gitea/workflows/build.yaml, que desde
# 2026-09-14 solo actualiza Staging automáticamente en cada push a main.
#
# Asume que este repo (scripts) y apps-registry están clonados como
# hermanos bajo el mismo directorio del lab (mismo layout que
# lab-cruzcloud/{apps-registry,platform-infra,scripts} en esta NAS).
APPS_REGISTRY_DIR="${APPS_REGISTRY_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../apps-registry" && pwd)}"
STAGING_MANIFEST="${APPS_REGISTRY_DIR}/workloads/ecommerce-staging/frontend/patches/deployment.yaml"
PROD_MANIFEST="${APPS_REGISTRY_DIR}/workloads/ecommerce/frontend/patches/deployment.yaml"
IMAGE_NAME="gitea.cruzcloud.net/devops/ecommerce-frontend"
test -d "${APPS_REGISTRY_DIR}/.git" || {
echo "ERROR: no se encontró un checkout de apps-registry en ${APPS_REGISTRY_DIR}."
echo "Definí APPS_REGISTRY_DIR=/ruta/al/checkout si no está junto a este repo."
exit 1
}
STAGING_VERSION="$(grep -oE "${IMAGE_NAME}:v[0-9.]+" "${STAGING_MANIFEST}" | head -1 | cut -d: -f2)"
test -n "${STAGING_VERSION}" || {
echo "ERROR: no se pudo leer la versión actual de Staging desde ${STAGING_MANIFEST}."
exit 1
}
CURRENT_PROD_VERSION="$(grep -oE "${IMAGE_NAME}:v[0-9.]+" "${PROD_MANIFEST}" | head -1 | cut -d: -f2)"
echo "Staging: ${STAGING_VERSION}"
echo "Producción: ${CURRENT_PROD_VERSION}"
if [[ "${STAGING_VERSION}" == "${CURRENT_PROD_VERSION}" ]]; then
echo "Producción ya está en ${STAGING_VERSION}. Nada que promover."
exit 0
fi
read -rp "¿Promover ${STAGING_VERSION} a Producción? [s/N] " CONFIRM
[[ "${CONFIRM}" =~ ^[sS]$ ]] || { echo "Cancelado."; exit 1; }
cd "${APPS_REGISTRY_DIR}"
git fetch origin main
git checkout -B main origin/main
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${STAGING_VERSION}|g" "${PROD_MANIFEST}"
# Mismo bump que hace build.yaml en Staging -- el footer lee APP_VERSION
# para mostrar la version real desplegada.
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${STAGING_VERSION}/" "${PROD_MANIFEST}"
git add "${PROD_MANIFEST}"
if git diff --cached --quiet; then
echo "No hay cambios en el manifiesto de Producción."
exit 0
fi
git commit -m "chore: promote ${STAGING_VERSION} to production"
git push origin HEAD:main
echo "Promovido. Argo CD sincroniza ecommerce-app en unos segundos."
+23
View File
@@ -0,0 +1,23 @@
# Imagen de tmux-host: sesion tmux persistente para Claude Code sobre el
# lab GitOps de cruzcloud. Reemplaza el `docker run ... alpine sh -c "apk
# add ... && npm install ..."` inline documentado antes en
# docs/arquitectura/sesion-tmux-persistente.md — mismo contenido, pero
# construido una vez como imagen en vez de reinstalar todo en cada arranque
# del contenedor.
FROM alpine:3.24
RUN apk add --no-cache tmux docker-cli bash curl git kubectl nodejs npm \
&& npm install -g @anthropic-ai/claude-code \
&& git config --global --add safe.directory '*'
# Repo raiz del lab (CLAUDE.md, apps-registry/, platform-infra/, scripts/).
# docker exec hereda este WORKDIR como cwd por defecto, y ademas
# lab-session.sh y healthcheck.sh lo pasan explicito via `tmux ... -c`
# para no depender de ese comportamiento implicito.
WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud
COPY healthcheck.sh /usr/local/bin/healthcheck.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/healthcheck.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Proceso principal (PID 1) del contenedor tmux-host: lanza el chequeo de
# salud en background y mantiene el contenedor vivo, igual que el
# `tail -f /dev/null` del `docker run` inline original.
set -eu
/usr/local/bin/healthcheck.sh &
exec tail -f /dev/null
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Chequeo de salud de tmux-host, corre en background dentro del propio
# contenedor (ver entrypoint.sh). Cada HEALTHCHECK_INTERVAL_SEC (30 min
# por defecto):
#
# a) Conectividad de red real (curl a gitea.cruzcloud.net). Si falla,
# SOLO se registra en el log y se notifica por Telegram (si el canal
# del lab-agent ya esta configurado) -- nunca se intenta "arreglar"
# la red del host desde aca, eso puede ser un problema de
# hardware/BIOS fuera del alcance del contenedor.
# b) Existencia de la sesion tmux "lab". Si no existe (crash, o el
# contenedor se acaba de recrear), SI se recrea automaticamente --
# es solo recuperar el propio entorno de trabajo, no tocar
# infraestructura externa. Al recrearla tambien se lanza `claude`
# adentro (ver launch_claude_in_session) -- una sesion recreada sin
# esto es un shell pelado, no "el entorno de trabajo".
#
# Todo evento (chequeo ok, falla, accion tomada) queda en LOG_FILE, que
# vive bajo /DATA (persistente, sobrevive a recrear el contenedor) y no
# solo en el stdout del contenedor.
#
# set -u sin 'e': un check individual que falle no debe matar el loop
# completo -- el siguiente ciclo tiene que seguir corriendo igual.
set -uo pipefail
WORKDIR="/DATA/AppData/k3d-lab-v3/lab-cruzcloud"
SESSION="lab"
LOG_DIR="/DATA/AppData/k3d-lab-v3/tmux-host/logs"
LOG_FILE="${LOG_DIR}/health.log"
# Reusa el mismo canal de Telegram que ya configuro scripts/lab-agent/ --
# una sola fuente de verdad para el bot token / chat id, en vez de
# duplicar credenciales entre los dos agentes del lab.
LAB_AGENT_ENV="${WORKDIR}/scripts/lab-agent/lab-agent.env"
NETWORK_CHECK_URL="https://gitea.cruzcloud.net/"
INTERVAL_SEC="${HEALTHCHECK_INTERVAL_SEC:-1800}"
# Cuanto esperar, SOLO al arrancar, a que WORKDIR este disponible antes de
# tocarlo. Tras un `docker restart` normal el host y sus mounts ya estaban
# arriba; tras un reinicio COMPLETO del NAS (corte de luz), Docker puede
# arrancar este contenedor antes de que /media/HDD-Storage termine de
# montarse en el host -- y WORKDIR llega a el via el symlink absoluto
# /DATA/AppData -> /media/HDD-Storage/AppData (ver el gotcha del symlink en
# docs-portal/docs/arquitectura/sesion-tmux-persistente.md). Sin esta
# espera, `mkdir -p "$LOG_DIR"` de mas abajo fallaba en silencio (set -u sin
# -e) y ese arranque quedaba SIN NINGUN rastro en el log -- asi se paso por
# alto originalmente el gotcha de "no lanzo claude" reportado 2026-09-19.
STARTUP_MOUNT_WAIT_MAX_SEC="${STARTUP_MOUNT_WAIT_MAX_SEC:-120}"
STARTUP_MOUNT_WAIT_STEP_SEC=5
wait_for_workdir() {
local waited=0
while [[ ! -d "$WORKDIR" ]] && [[ "$waited" -lt "$STARTUP_MOUNT_WAIT_MAX_SEC" ]]; do
sleep "$STARTUP_MOUNT_WAIT_STEP_SEC"
waited=$((waited + STARTUP_MOUNT_WAIT_STEP_SEC))
done
echo "$waited"
}
STARTUP_WAITED_SEC="$(wait_for_workdir)"
mkdir -p "$LOG_DIR" 2>/dev/null || true
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" >> "$LOG_FILE" 2>/dev/null || true
}
if [[ ! -d "$WORKDIR" ]]; then
log "ARRANQUE: ERROR -- ${WORKDIR} sigue sin existir tras esperar ${STARTUP_MOUNT_WAIT_MAX_SEC}s. El mount de /DATA o /media/HDD-Storage del host no esta listo; revisar fuera del contenedor."
elif [[ "$STARTUP_WAITED_SEC" -gt 0 ]]; then
log "ARRANQUE: ${WORKDIR} tardo ${STARTUP_WAITED_SEC}s en estar disponible (probable reinicio completo del host reordenando el montaje de /DATA)."
fi
notify_telegram() {
local title="$1" message="$2"
if [[ ! -f "$LAB_AGENT_ENV" ]]; then
log "NOTIFY omitida: no existe ${LAB_AGENT_ENV}"
return 0
fi
local token_file chat_id token resp
token_file="$(grep -E '^TELEGRAM_BOT_TOKEN_FILE=' "$LAB_AGENT_ENV" | cut -d= -f2-)"
chat_id="$(grep -E '^TELEGRAM_CHAT_ID=' "$LAB_AGENT_ENV" | cut -d= -f2-)"
if [[ -z "$token_file" || ! -r "$token_file" || -z "$chat_id" ]]; then
log "NOTIFY omitida: canal de Telegram del lab-agent no esta configurado todavia"
return 0
fi
token="$(<"$token_file")"
resp="$(curl -s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage" \
--data-urlencode "chat_id=${chat_id}" \
--data-urlencode "text=🤖 tmux-host healthcheck
${title}
──────────────
${message}" 2>&1)"
token=""
[[ "$resp" == *'"ok":true'* ]] || log "WARN: fallo al notificar por Telegram: ${resp}"
}
check_network() {
local code
code="$(curl -sL -o /dev/null -w '%{http_code}' --max-time 10 "$NETWORK_CHECK_URL" 2>/dev/null || echo "000")"
if [[ "$code" == "000" ]]; then
log "RED: sin conectividad real (curl ${NETWORK_CHECK_URL} sin respuesta). No se intenta corregir desde el contenedor."
notify_telegram "Sin conectividad" "curl ${NETWORK_CHECK_URL} no respondio desde tmux-host (posible falla de red del host/NAS, ej. hardware/BIOS). El contenedor NO intenta corregir esto, solo lo registra."
else
log "RED: ok (curl ${NETWORK_CHECK_URL} -> HTTP ${code})"
fi
}
launch_claude_in_session() {
# El pane recien creado tarda un instante en tener su shell listo para
# recibir input -- un send-keys inmediato a veces se pierde. 1s alcanza
# sobrado para un shell interactivo local (no depende de red).
sleep 1
# --remote-control: sin esto la sesion nunca queda visible como
# controlable remotamente en claude.ai (confirmado en vivo 2026-09-20 --
# sin la flag, "remoteEnabled" queda en false para siempre porque nadie
# la pide). El nombre de sesion remota usa el hostname del contenedor
# (ZimaOS) como prefijo por defecto.
if tmux send-keys -t "$SESSION" "claude --remote-control" Enter 2>>"$LOG_FILE"; then
log "TMUX: 'claude --remote-control' lanzado dentro de la sesion recien creada"
else
log "TMUX: ERROR al lanzar 'claude --remote-control' dentro de la sesion recien creada"
fi
}
check_tmux_session() {
if tmux has-session -t "$SESSION" 2>/dev/null; then
log "TMUX: sesion '${SESSION}' ok"
return
fi
if [[ ! -d "$WORKDIR" ]]; then
# No se crea sobre un WORKDIR ausente (quedaria posicionada en /). Se
# reintenta en el proximo ciclo -- ver STARTUP_MOUNT_WAIT_MAX_SEC arriba
# para el caso especifico de arranque en frio del host.
log "TMUX: sesion '${SESSION}' no existe y ${WORKDIR} tampoco -- no se crea esta vuelta, se reintenta en el proximo ciclo (${INTERVAL_SEC}s)."
return
fi
log "TMUX: sesion '${SESSION}' no existe -- recreando en ${WORKDIR}"
if tmux new-session -d -s "$SESSION" -c "$WORKDIR" 2>>"$LOG_FILE"; then
log "TMUX: sesion '${SESSION}' recreada exitosamente"
# Una sesion recreada sin esto es un shell vacio hasta que alguien
# entre a mano y escriba "claude" -- exactamente el gotcha reportado
# tras el reinicio completo del NAS del 2026-09-18/19: la sesion SI se
# recreaba sola (confirmado en el log de ese arranque), pero Claude
# Code nunca arrancaba adentro.
launch_claude_in_session
else
log "TMUX: ERROR al recrear la sesion '${SESSION}'"
fi
}
log "=== healthcheck inicio (intervalo=${INTERVAL_SEC}s) ==="
while true; do
check_network
check_tmux_session
sleep "$INTERVAL_SEC"
done