Compare commits

..
Author SHA1 Message Date
devops 09c756942b chore: promote v1.0.284 to production [skip ci] 2026-09-27 02:17:10 +00:00
devops a9ef0ed00a chore: release v1.0.284 [skip ci] 2026-09-27 02:17:08 +00:00
devops da7f316eb1 Merge branch 'feat/product-card-galeria-swipe'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 2m4s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m27s
2026-09-27 01:37:26 +00:00
devopsandClaude Opus 5.5 123cbbd87a feat(storefront): galería deslizable en las tarjetas de producto del listado
Permite ver todas las imágenes de un producto desde el catálogo y los
carruseles sin entrar a la ficha: swipe con scroll-snap nativo en
móvil, flechas al pasar el mouse en escritorio (pointer-fine) y puntos
de posición. Cada imagen sigue enlazando a la ficha; productos con una
sola imagen no muestran controles. overscroll-behavior-x: contain evita
que el swipe de imágenes mueva el carrusel de productos del inicio.

Validado antes del merge con build de producción local (datos mock) y
Playwright con eventos táctiles reales a 390/320 px y 1280 px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 01:37:25 +00:00
gitea-actions 496ad982f3 chore(gitops): deploy Docs Portal v1.0.283 [skip ci] 2026-09-27 00:31:40 +00:00
devops fa183a7115 Merge branch 'docs/tmux-host-ipv6-confirmado-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 13m45s
2026-09-27 00:16:38 +00:00
devopsandClaude Opus 5.5 4146245ae9 docs(playbooks): IPv6 confirmado tras segundo reinicio, cierre de pendientes
El segundo reinicio real del NAS confirmo que /etc/sysctl.d/99-disable-ipv6.conf
se aplica al arrancar (disable_ipv6=1 sin sysctl -p manual). Se cierran
todos los pendientes del playbook y se reescribe la lista sin sintaxis de
tasklist/tachado, que el portal renderizaba literal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 00:16:37 +00:00
gitea-actions a38edfecc8 chore(gitops): deploy Docs Portal v1.0.282 [skip ci] 2026-09-26 19:33:01 +00:00
devops 2566e06abd Merge branch 'docs/tmux-host-ipv6-reinicio-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m35s
2026-09-26 19:22:40 +00:00
devopsandClaude Opus 5.5 c7ac1b52be docs(playbooks): resultado del reinicio real, IPv6 via /etc/sysctl.d en ZimaOS
La prueba de reinicio del NAS (2026-09-26) mostro que /etc/sysctl.conf
persiste pero ZimaOS no lo aplica al arrancar (systemd-sysctl solo lee
/etc/sysctl.d/*.conf, sin symlink 99-sysctl.conf). Se documenta la causa,
el fix con /etc/sysctl.d/99-disable-ipv6.conf, como aplicarlo en caliente
con un contenedor privilegiado de un solo uso, y se cierran los pendientes
ya resueltos (commit de entrypoint.sh, limpieza de etiquetas).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 19:22:39 +00:00
gitea-actions 2e7a3f3d24 chore(gitops): deploy Docs Portal v1.0.281 [skip ci] 2026-09-26 14:51:18 +00:00
devops d9c2037c59 Merge branch 'docs/incidente-tmux-host-2026-09-25'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m15s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 14m51s
2026-09-26 12:39:31 +00:00
devopsandClaude Opus 5.5 7549dc86d2 docs(playbooks): incidente cadena de 4 fallos en tmux-host (2026-09-25/26)
Documenta la cadena de causas encontradas tras eliminar tmux-host por
"no toma las sesiones remotas": volumenes de auth faltantes al recrear,
set -e matando al supervisor del healthcheck, IPv6 roto en el NAS
(timeout a Anthropic) y, como causa original, el token OAuth vencido.
Agrega la fila de expiracion OAuth a "Que sobrevive a que" en
sesion-tmux-persistente.md con enlace cruzado al playbook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 12:39:30 +00:00
gitea-actions 8e0d6abb82 chore(gitops): deploy Docs Portal v1.0.280 [skip ci] 2026-09-21 14:10:53 +00:00
devopsandClaude Sonnet 5 d98ec9a488 Merge branch 'docs/adr-0013-iniciativas-clientes-2026-09-21'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m48s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 14:01:30 +00:00
devopsandClaude Sonnet 5 03cfb203eb docs(adr-0013): iniciativas de clientes (Apps Script + AppSheet), fase prioritaria
Nueva fase, separada y con prioridad sobre las Fases 2-4 del ADR 0012:
practica de Apps Script (via clasp, sin MCP -- se verifico que no existe
uno oficial de Google) y AppSheet (documentado desde afuera, sin export
de codigo posible, verificado contra la documentacion oficial) para
iniciativas reales de la empresa con cuenta personal, sin datos ni
credenciales de cliente en ningun repo/doc. Deja explicito lo que queda
en pausa mientras esta fase este activa: swap de tmux-host, Fase 4 del
ADR 0012 (GKE), prueba del skill frontend-design.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 14:01:17 +00:00
gitea-actions 4553967bec chore(gitops): deploy Docs Portal v1.0.279 [skip ci] 2026-09-21 04:26:38 +00:00
devopsandClaude Sonnet 5 059597346d docs(adr-0012): documentar pivot de Fase 1 a Connectors nativos de claude.ai
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 36s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m20s
El MCP self-hosted (taylorwilsdon/google_workspace_mcp) quedo abandonado:
limite real en FastMCP's OAuthProxy, no error de configuracion -
redirect_uri se arma desde base_url (no desde GOOGLE_OAUTH_REDIRECT_URI),
y base_url tambien alimenta issuer_url, que mcp/server/auth/routes.py
exige HTTPS-o-localhost sin bypass. Documenta la causa raiz confirmada
leyendo el codigo fuente instalado, la decision del usuario de usar
Connectors nativos en su lugar, y la verificacion en vivo (3 calendarios
reales, razonamiento de fechas correcto).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 04:16:19 +00:00
gitea-actions b55766847f chore(gitops): deploy Docs Portal v1.0.278 [skip ci] 2026-09-20 23:17:55 +00:00
devops 848bac0b2d Merge branch 'docs/plugins-y-subagentes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 57s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 11m58s
2026-09-20 23:04:41 +00:00
devops 8107f9b6c7 docs(arquitectura): documentar plugins y subagentes instalados en sesion lab 2026-09-20 23:04:20 +00:00
gitea-actions d82f4df83a chore(gitops): deploy Docs Portal v1.0.277 [skip ci] 2026-09-20 21:11:43 +00:00
devops 55eb9d8051 Merge branch 'docs/adr-0012-practica-multicloud-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 7m47s
2026-09-20 21:03:22 +00:00
gitea-actions c245f3b67a chore(gitops): deploy Docs Portal v1.0.276 [skip ci] 2026-09-20 20:42:06 +00:00
11 changed files with 813 additions and 25 deletions

No files matched your search

@@ -0,0 +1,111 @@
# Plugins y subagentes de Claude Code (sesión "lab")
Inventario de qué plugins/skills se instalaron en la sesión principal
`lab` (no `cloud-practice`, que sigue su propia línea de práctica
multi-cloud — ver [ADR 0012](../decisiones/0012-practica-multicloud-gcp-azure.md))
y qué subagentes propios se configuraron, con la fuente real de cada
uno y por qué se aceptó o se descartó.
## Por qué esto queda documentado como el resto del lab
Dos frentes reales motivaron esto: (a) trabajo visual del storefront
ARI Shopping — ADR 0008 ya costó tres ciclos completos de
implementar-y-descartar una identidad en producción por no verla
renderizada antes de aprobarla; (b) crecimiento de habilidades de
Platform Engineering/DevOps. Un plugin instala instrucciones que
Claude sigue automáticamente, algunos con hooks que se ejecutan sin
pedir confirmación en cada uso — se aplicó la misma disciplina de
verificación que el resto del lab: quién publica (Anthropic vs
comunitario), qué permisos/hooks declara, y popularidad como señal de
confianza, antes de instalar cualquiera que no fuera 100% de Anthropic.
## Plugins instalados
| Plugin | Marketplace | Publica de verdad | Hooks | Notas |
|---|---|---|---|---|
| `frontend-design` | `claude-plugins-official` | **Anthropic** (verificado en `plugin.json`) | Ninguno | Solo skills, sin red externa |
| `context7` | `claude-plugins-official` | **Upstash** (tag `community-managed` — está *hospedado* en el catálogo oficial, no *publicado* por Anthropic) | Sin hooks, pero es un MCP que manda cada consulta al servidor remoto de Upstash (`mcp.context7.com`) | Aceptado con esa salvedad explícita de datos saliendo a un tercero |
| `superpowers` | `claude-plugins-official` | **Jesse Vincent** (`obra/superpowers`, MIT, 288k+ ⭐ en GitHub) — **NO es de Anthropic** pese a estar listado en el catálogo oficial | Sí: `SessionStart` corre un script que inyecta contexto en cada sesión nueva (revisado: solo lee su propio skill local, sin red ni nada destructivo) | Aceptado con esa salvedad — cambia comportamiento de arranque sin pedir confirmación cada vez |
| `gitops-workflows` | `devops-skills` (`ahmedasmar/devops-claude-skills`) | Ahmad Asmar (comunitario) | Ninguno (skill pura, sin ejecución automática) | |
| `k8s-troubleshooter` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `ci-cd` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `monitoring-observability` | `devops-skills` | Ahmad Asmar | Ninguno | |
**Descartados, no instalados:**
- **`iac-terraform`** (del mismo marketplace `devops-skills`) — descartado a propósito, no por falta de calidad. Terraform ya se maneja aparte vía `hashicorp/agent-skills` en la sesión `cloud-practice` (ver ADR 0012 Fase 2); instalarlo acá también duplicaría/confundiría la fuente de verdad.
- **`aws-cost-optimization`** (mismo marketplace) — no se pidió, se dejó afuera para no sumar alcance no solicitado.
- **`theme-factory`** — investigado y descartado: el mismo nombre aparece en al menos 4 fuentes comunitarias distintas y no relacionadas entre sí (`kivilaid`, `shipshitdev`, `composiohq`, etc.), ninguna en el catálogo oficial de Anthropic. Sin una fuente única verificable, no se instaló nada con ese nombre.
- **`ui-theme-designer`** (alternativa considerada del catálogo oficial) — es de **SAP SE**, específico para el SAP Fiori Design System / UI5 Web Components. No tiene nada que ver con el stack real de ARI Shopping (Next.js + Tailwind) — se descartó por no aplicar, no por falta de confianza.
## Subagentes propios (`~/.claude/agents/`)
Tres subagentes con propósito acotado y tools restringidas, definidos
como archivos markdown con frontmatter YAML (formato estándar de
Claude Code — ver [documentación oficial](https://code.claude.com/docs/en/sub-agents)).
### `devsecops-auditor`
Solo lectura y explicación del pipeline DevSecOps real (Gitleaks,
Trivy, Semgrep, Syft, Cosign) en `apps-registry`/`platform-infra`/
`scripts`. `tools: Read, Grep, Glob, Bash` + `disallowedTools: Write,
Edit` — nunca modifica código de producción, su único output es
diagnóstico contra el YAML/config real, citando archivo:línea, nunca
teoría genérica de CI/CD.
### `frontend-critic`
Revisa visualmente un cambio del storefront **antes** de aprobarlo —
formaliza la disciplina "simular antes de implementar" que costó tres
ciclos de descarte en ADR 0008. Regla dura: si no puede ver el
resultado renderizado en esa corrida puntual, el veredicto es "no
puedo aprobar esto sin verlo", nunca un aprobado condicional.
Vía de verificación, en orden de preferencia:
1. Chrome DevTools / Claude in Chrome — **hoy no disponible en esta
sesión** (corre headless dentro de `tmux-host`, sin navegador real
conectado — mismo gotcha ya documentado para Claude in Chrome en
general).
2. Playwright vía Docker en la NAS (el workaround ya validado este
mismo lab, ver `.browser-scratch/README.md`) contra
`shop.cruzcloud.net`/`staging.cruzcloud.net` reales.
Usa la skill `frontend-design` (oficial, ver tabla de arriba) como
marco de criterio de calidad visual.
### `cloud-cost-auditor`
Definido de antemano, a propósito, **sin nada que auditar todavía**.
Necesita los MCP de GCP/Azure que se configuran en la sesión
`cloud-practice` (ADR 0012) para funcionar — no va a tener recursos
reales que revisar hasta que esa línea de trabajo pase de la Fase 1.
Su propio system prompt tiene la regla dura de decir explícitamente
"no tengo nada que auditar todavía" en vez de inventar un análisis si
lo invocan antes de tiempo. No es un bloqueante para nada de lo demás,
solo queda listo para cuando aplique.
## Alcance y persistencia de la instalación
Los 7 plugins quedaron en **`scope: user`** (confirmado con
`claude plugin list`), no a nivel de proyecto de un repo específico.
Los 3 subagentes viven en `~/.claude/agents/` (también scope de
usuario, ubicación estándar según la doc oficial).
**Verificado en vivo que sobrevive a un reinicio del contenedor**,
mismo criterio que el resto de la configuración persistente de
`tmux-host` (ver [Sesión tmux persistente](sesion-tmux-persistente.md)):
`stat -c '%d' /root/.claude` y `stat -c '%d'
/DATA/AppData/k3d-lab-v3/claude-code-home` devuelven el mismo device
ID — es el mismo bind mount ya validado para login/confianza de
carpeta, no un directorio efímero del contenedor.
**Gotcha encontrado en vivo:** tanto los plugins como los subagentes
nuevos **no quedaron activos en la misma sesión donde se instalaron/
crearon** — la doc oficial de subagentes dice que se cargan sin
reinicio, *excepto* la primera vez que se crea el directorio
`~/.claude/agents/` (que no existía antes en este lab). Confirmado en
vivo: invocar `devsecops-auditor` en la misma sesión dio
`Agent type 'devsecops-auditor' not found`. Ambos (plugins y
subagentes) quedan pendientes de confirmar activos recién en el
próximo arranque real de la sesión — no es una falla de instalación,
es el comportamiento documentado.
@@ -408,6 +408,14 @@ después del swap.
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` | Sí, desde el 2026-09-20 — `--continue` retoma la conversación más reciente en `WORKDIR` (ver gotcha arriba) |
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) | Sí, mismo mecanismo — validado en vivo durante el swap de imagen del 2026-09-20 |
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A |
| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso |
!!! warning "Incidente relacionado"
La fila de expiración del token OAuth salió del
[incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md).
El síntoma "no aparecen las sesiones remotas" llevó a eliminar el
contenedor, aunque la causa real era un token vencido. **Antes de
tocar el contenedor, mirá el pane de cada sesión.**
## Validación en vivo
@@ -1,9 +1,13 @@
# ADR 0012 — Práctica multi-cloud: GCP y Azure DevOps en paralelo al lab ZimaOS
**Estado:** aceptada — Fase 1 (Google Workspace) en ejecución en esta
misma sesión; Fases 2-4 quedan documentadas como roadmap, explícitamente
no implementadas hasta que se pidan en una sesión futura.
**Fecha:** 2026-09-20
**Estado:** aceptada — Fase 1 (Google Workspace) completa y verificada
en vivo, con un giro real: el MCP self-hosted planeado
(`taylorwilsdon/google_workspace_mcp`) quedó abandonado por un límite
real de esa librería (ver §Fase 1), reemplazado por los Connectors
nativos de claude.ai. Fases 2-4 quedan documentadas como roadmap,
explícitamente no implementadas hasta que se pidan en una sesión
futura.
**Fecha:** 2026-09-20 (Fase 1 cerrada 2026-09-21)
## Contexto
@@ -36,7 +40,7 @@ empujan a abrir una segunda línea de práctica en paralelo:
arquitectónica real, diagramas Mermaid, cifras verificadas en vivo o
contra fuente primaria (no contra un blog de terceros sin
corroborar — ver el caso del supuesto MCP "oficial" de Google
Workspace en la [Fase 1](#fase-1-ejecutar-en-esta-sesion-google-workspace)).
Workspace en la [Fase 1](#fase-1-ejecutada-google-workspace-con-un-giro-real-en-la-implementacion)).
## Diagrama del lab actual (ZimaOS)
@@ -194,20 +198,60 @@ Todas documentadas acá; **solo la Fase 1 se ejecuta en esta sesión.**
Las Fases 2-4 no se implementan sin pedido explícito en una sesión
futura — quedan como roadmap, no como trabajo en curso.
### Fase 1 (ejecutar en esta sesión) — Google Workspace
### Fase 1 (ejecutada) — Google Workspace, con un giro real en la implementación
MCP de Google Workspace vía cuenta personal, para las iniciativas ya
en curso. Detalle de la investigación previa a implementar (qué MCP
usar, oficial vs comunitario) en la Parte 3 de esta conversación —
resumen: **no existe un MCP oficial de Google Workspace** publicado
por Google (se verificó explícitamente buscando en los GitHub
`google`/`googleworkspace` oficiales — no aparece ninguno; un solo
blog de terceros afirma que "ya existe uno oficial" sin que ninguna
otra fuente lo corrobore, así que se descarta esa afirmación por no
verificable). `taylorwilsdon/google_workspace_mcp` es la opción
comunitaria más completa y activamente mantenida (120+ herramientas,
OAuth 2.1, +2500 estrellas) — es la que se usa, etiquetada como
comunitaria, no oficial.
**Objetivo cumplido — acceso real a Google Calendar desde Claude,
verificado en vivo — pero por un camino distinto al planeado
originalmente.** Detalle de la investigación previa (qué MCP usar,
oficial vs comunitario) — resumen: **no existe un MCP oficial de
Google Workspace** publicado por Google (se verificó explícitamente
buscando en los GitHub `google`/`googleworkspace` oficiales — no
aparece ninguno; un solo blog de terceros afirma que "ya existe uno
oficial" sin que ninguna otra fuente lo corrobore, así que se descarta
esa afirmación por no verificable). Se optó por
`taylorwilsdon/google_workspace_mcp` (comunitario, el más completo y
activamente mantenido: 120+ herramientas, OAuth 2.1, +2500 estrellas)
como primer intento, self-hosted dentro de `tmux-host`.
**Ese self-hosted quedó abandonado — límite real encontrado en la
librería, no un error de configuración.** El servidor usa FastMCP's
`GoogleProvider`/`OAuthProxy`, que construye el `redirect_uri`
enviado a Google directamente desde su propio `base_url` — **no**
desde la variable `GOOGLE_OAUTH_REDIRECT_URI` que el proyecto expone
(esa variable solo valida entradas, no arma la salida real; hallazgo
confirmado leyendo el código fuente instalado, no la documentación
pública). Y `base_url` es la misma variable de la que depende por
default `issuer_url`, que el SDK de MCP exige sea HTTPS salvo que sea
literalmente `localhost`/`127.0.0.1` (chequeo hardcodeado en
`mcp/server/auth/routes.py:validate_issuer_url`, sin variable de
entorno que lo salte). Resultado: **no hay combinación de variables
de entorno que satisfaga a la vez** "el navegador del usuario debe
poder llegar al callback" (necesita un dominio real, no `localhost`)
y "el issuer debe ser HTTPS o `localhost`" — `workspace-mcp` no
expone `issuer_url` por separado de `base_url` en su capa de
configuración (confirmado en `core/server.py`: solo pasa `base_url` a
`GoogleProvider(...)`).
**Decisión del usuario ante ese límite:** en vez de armar un proxy
HTTPS con certificado autofirmado (infraestructura extra, fricción de
certificado no confiable en cada uso) o parchear el paquete instalado
a mano (frágil, se pierde en cualquier reinstalación), se usaron los
**Connectors nativos de claude.ai** (Google Calendar, vía el
directorio de conectores de la cuenta, fuera de Claude Code por
completo) — mismo resultado práctico (Claude con acceso real al
calendario personal), infraestructura cero de nuestro lado, sin
tocar código de terceros. Contrapartida real: es una integración de
cuenta de claude.ai, no un MCP configurado dentro del proyecto
`platform-multicloud` — no aparece como herramienta en sesiones de
Claude Code (`lab`/`cloud-practice`), solo en el chat normal de
claude.ai.
**Verificado en vivo:** pregunta real en claude.ai normal ("¿qué
tengo en el calendario esta semana?") devolvió una respuesta correcta
revisando 3 calendarios reales de la cuenta (principal, Familia,
festivos de Colombia) vía la herramienta `Google Calendar: Lists
calendar events`, con razonamiento correcto sobre qué cuenta como
"esta semana" dado el día/hora real de la consulta.
### Fase 2 (documentar, no ejecutar) — Terraform aislado
@@ -0,0 +1,122 @@
# ADR 0013 — Iniciativas de clientes: Google Apps Script y AppSheet
**Estado:** aceptada — **fase prioritaria**, precede la reanudación de
las Fases 2-4 del [ADR 0012](0012-practica-multicloud-gcp-azure.md).
Scaffold del repo en curso; primeras iniciativas reales se agregan
sesión a sesión, a medida que se comparten.
**Fecha:** 2026-09-21
## Contexto
Iniciativas propias de la empresa (no del lab), trabajadas con **cuenta
personal de Google** — dos herramientas concretas: **Google Apps
Script** (código, automatizaciones) y **Google AppSheet** (apps
no-code sobre Sheets). Se abre como fase separada del ADR 0012 y con
prioridad sobre él por decisión explícita: retomar las Fases 2-4 del
lab (Terraform, Azure DevOps, GKE) espera a que esto avance primero.
**Restricción dura, no negociable:** ningún repo ni documento de esta
iniciativa contiene credenciales, datos ni información real de
cliente. Apps Script versiona código (lógica), nunca datos. AppSheet
no tiene export de código — solo se documenta desde afuera (tablas,
columnas, expresiones, capturas), nunca se copian datos reales de
ninguna hoja de cálculo al repo.
## Opciones consideradas
### Apps Script: MCP vs. `clasp`
| Opción | A favor | En contra |
|---|---|---|
| MCP para Apps Script | Interacción conversacional directa | **No existe un MCP oficial de Google.** Se verificó explícitamente buscando — solo hay servidores comunitarios (`mohalmah/google-appscript-mcp-server`, `whichguy/gas_mcp`, entre otros), ninguno publicado por Google. Se descartan sin evaluar más a fondo: no hay necesidad de agregar una dependencia comunitaria no verificada cuando existe una CLI oficial que cubre el caso de uso completo. |
| **`clasp`** (elegido) | CLI oficial de Google (`@google/clasp`), mantenida por el equipo de Apps Script, cubre push/pull/login/versiones/despliegues | Requiere login interactivo (OAuth) — mitigado: el login lo corre el dueño de la cuenta, nunca se comparte el código de autorización |
### AppSheet: qué se puede versionar
Se verificó contra la documentación oficial vigente (no se asumió):
**AppSheet no expone ningún mecanismo de export de la definición de la
app** (tablas virtuales, vistas, expresiones, automatizaciones) hacia
un formato versionable con git. La única superficie exportable es la
app en sí vía la consola de AppSheet, no un artefacto de código. Existe
un addon de terceros ("App Script Saver") pero es específico de Apps
Script, no de AppSheet, y no se evaluó como parte de esta decisión
(mismo criterio que con los MCP: no se instala nada de terceros sin
pasar primero por la verificación de publicador/permisos ya establecida
en el lab).
**Decisión sobre AppSheet:** no se intenta versionar la app —se
documenta desde afuera. La documentación (markdown + capturas) es la
única fuente versionada; la app real en la consola de AppSheet sigue
siendo la fuente de verdad operativa, con el riesgo explícito de que
la documentación quede desactualizada (ver Consecuencias).
## Decisión
Repo privado nuevo `client-initiatives` en Gitea (separado de
`apps-registry`/`platform-infra`/`scripts`/`platform-multicloud` — ni
lab GitOps ni práctica multi-cloud, es su propia categoría). Estructura:
una carpeta por iniciativa bajo `apps-script/` (proyecto `clasp`
completo, con su propio `.clasp.json`) o `appsheet/` (documentación +
plantilla de estructura vacía). Mismo criterio de versionado semántico
y modularidad que ya usa `docs-portal`.
**Flujo Apps Script:** rama `fix/*` → diff mostrado → `merge --no-ff`
a `main`. `clasp push` (subir el código a Google) es una confirmación
explícita **aparte**, nunca agrupada con el merge a git — son dos
sistemas de versionado distintos (git para el repo, Apps Script para
las versiones desplegadas) y no deben confundirse.
**Gitleaks:** se agrega al pipeline del repo nuevo. Nota real
encontrada al planificar esto: Gitleaks hoy solo corre en
`apps-registry` (3 workflows) — `platform-infra` y `scripts` no tienen
Gitea Actions configurado en absoluto, no hay "los otros tres repos con
Gitleaks" como se asumió al principio. Se replica el step exacto que
ya usa `apps-registry` (Gitleaks v8.21.2, `--no-git --redact`, reporte
JSON) por ser el único precedente real en el lab.
## Fases
### Fase A — Google Apps Script
1. Scaffold del repo `client-initiatives` (estructura, Gitleaks,
convenciones documentadas).
2. Login de `clasp` (`clasp login --no-localhost`) corrido por el
dueño de la cuenta — verificación posterior de que
`~/.clasprc.json` queda fuera de cualquier repo y con `chmod 600`.
3. Primeras iniciativas: pendientes de compartir, se agregan una por
una bajo `apps-script/<slug>/` a medida que llegan.
### Fase B — Google AppSheet
1. Por cada app existente: documentar tablas, columnas y expresiones
en markdown, más capturas del estado actual, bajo
`appsheet/<slug>/`.
2. Plantilla de estructura de datos vacía si aplica (schema, nunca
filas reales).
3. Limitación dejada explícita en cada README de iniciativa: sin diff
automático posible entre esta doc y la app real.
## En pausa mientras esta fase esté activa
Por decisión explícita del usuario, prioridad sobre lo ya documentado
en el ADR 0012 y el resto del lab:
- **Swap de `tmux-host`** — no se corre en esta fase.
- **[Fase 4 del ADR 0012](0012-practica-multicloud-gcp-azure.md#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)**
(GKE real con Spot + auto-apagado) — sigue solo documentada.
- **Prueba del skill `frontend-design`** (pendiente desde la
instalación de plugins del 2026-09-20) — no se ejecuta.
Tampoco se toca NPM, `cloudflared`, ni `lab-agent` durante esta fase.
## Consecuencias
- Se gana un espacio versionado y auditado (Gitleaks) para
automatizaciones reales de la empresa, sin mezclar nada con el lab
GitOps ni con la práctica multi-cloud.
- Se acepta como deuda consciente que la documentación de AppSheet
puede desincronizarse de la app real — no hay forma técnica de
evitarlo, solo disciplina de actualizarla.
- Revertir esta decisión implicaría: eliminar el repo
`client-initiatives` (las iniciativas seguirían existiendo en Google,
solo se perdería el historial versionado y la documentación externa).
@@ -0,0 +1,385 @@
# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26)
| | |
|---|---|
| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z |
| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) |
| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic |
| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa |
## Contexto / disparador
El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las
sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai.
Parecía un problema del contenedor, así que se eliminó `tmux-host` para
recrearlo desde cero.
En realidad ese síntoma tenía una causa que no tenía nada que ver con el
contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al
recrear el contenedor, la mitad de la noche se fue en resolver tres fallos
reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada
uno se encontró recién después de resolver el anterior. Recién al final
apareció la causa original.
!!! info "Fuentes de la evidencia"
Este playbook se escribió desde dentro del `tmux-host` ya recuperado.
Los datos marcados **(verificado)** se confirmaron contra el estado
real del sistema al escribirlo: `docker inspect`, `docker images`,
`health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los
marcados **(reportado)** vienen del relato de la sesión de
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
transcript ni historial de shell que se pueda revisar.
---
## Causa a — Contenedor recreado sin los volúmenes de autenticación
**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el
diálogo de confianza del folder. `--continue` no encontraba ninguna
conversación previa y arrancaba una en blanco.
**Diagnóstico.** El `docker run` que se usó para recrear el contenedor
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)
**(reportado)**. Faltaban justamente los dos de autenticación y estado
de Claude Code:
| Volumen faltante | Qué se perdía |
|---|---|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria |
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control |
Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
se reconstruyó varias veces con el mismo comando incompleto antes de
comparar contra el doc de arquitectura **(reportado)**. `health.log`
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**.
**Fix.** Recrear el contenedor con el `docker run` completo de la
sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes
van juntos, sin excepciones.
**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el
contenedor final (creado `2026-09-26T06:10:55Z`, imagen
`tmux-host:2026-09-26-fix-real`) lista los 5 mounts:
```
/var/run/docker.sock -> /var/run/docker.sock
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
/DATA -> /DATA
/media/HDD-Storage -> /media/HDD-Storage
```
La sesión `lab` retomó la conversación anterior con `--continue`, sin
pedir de nuevo la confianza del folder.
!!! warning "Lección"
**Antes de improvisar un `docker run` de memoria, leer siempre el doc
de arquitectura que ya existe.** El comando completo y el motivo de
cada volumen ya estaban documentados en `sesion-tmux-persistente.md`.
El tiempo perdido reconstruyendo con un comando incompleto salió de no
abrirlo primero.
---
## Causa b — `set -e` heredado mataba al supervisor del healthcheck
**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo
relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor
seguía vivo sin healthcheck.
**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El
supervisor corre como función en un subshell (`supervise_healthcheck &`)
y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de
cero, `set -e` mataba al propio subshell del supervisor *en esa misma
línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban.
Es decir, el supervisor moría justo en el único caso para el que existía.
**Paso en falso: el fix que nunca llegó al disco.** La primera
"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la
imagen se reconstruyó dos veces con el contenido viejo **(reportado)**.
La evidencia sigue en el host **(verificado)**: las etiquetas
`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2`
apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las
capas porque el archivo no había cambiado. Recién después de confirmar
con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se
construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z).
**Fix.** En `scripts/tmux-host/entrypoint.sh`:
```diff
-set -eu
+set -u
-/usr/local/bin/healthcheck.sh &
+supervise_healthcheck() {
+ while true; do
+ /usr/local/bin/healthcheck.sh
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
+ sleep 10
+ done
+}
+supervise_healthcheck &
exec tail -f /dev/null
```
**Validación en vivo (verificado).** `health.log` registra el supervisor
actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró
el aviso y se relanzó solo 10 segundos después:
```
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
```
!!! warning "Lección"
**Verificar el archivo en disco (`cat`, `git diff`) ANTES de
reconstruir la imagen.** No hay que asumir que el editor guardó el
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
no tuvo cambios.
---
## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic
**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al
arrancar con `Unable to connect to Anthropic services` a los ~10 s.
**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**:
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
instante con `Network unreachable`.
3. El fallback automático a IPv4 terminaba funcionando, pero la conexión
completa tardaba **~14 s**. Eso es más que el timeout interno de
Claude Code (~10 s), así que el CLI se rendía antes de que el
fallback llegara a conectar.
**Primer intento: parche parcial.**
`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
**(reportado)**, y además solo corregía el proceso de Node, no la red del
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
`NODE_OPTIONS` en su entorno **(verificado)**.
**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS,
en caliente y persistente:
```bash
# En caliente
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
```
Esto se tiene que hacer **en el host, no dentro del contenedor**.
`tmux-host` corre con `--network host`, así que comparte el network
namespace del NAS. Docker no permite fijar sysctls de red
(`--sysctl net.*`) en un contenedor con `--network host`, porque
cambiarían la red de todo el host.
**Validación en vivo (verificado, 2026-09-26 ~06:40Z):**
```
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
$ getent ahosts api.anthropic.com
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
```
`/etc/sysctl.conf` en el host contiene las dos líneas (escrito
2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura,
pero `/etc` es un overlay con capa de escritura en
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
persiste.
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
Resultado **(verificado)**:
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
(el overlay de `/etc` persiste).
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
archivo existía y **nunca se aplicó** al arrancar.
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
fallando (`Could not connect to server`). Si el router vuelve a entregar
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
no porque estuviera corregido.
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
Hace falta un contenedor privilegiado de un solo uso en la red del host,
o `sudo sysctl -p` directo en el host:
```bash
docker run --rm --privileged --network host \
-v /etc/sysctl.d:/etc/sysctl.d:ro \
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
```
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
!!! warning "Lección"
"El archivo de configuración persistió" no es lo mismo que "la
configuración se aplica al arrancar". La única validación real de
algo persistente es **reiniciar y medir el valor efectivo**, no
revisar que el archivo siga ahí.
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
`tentative`), que no sale a internet y no afecta.
Sigue sin verificarse si una **actualización de ZimaOS** pisa
`upper_etc`. Después de cada update del SO, revisar
`sysctl net.ipv6.conf.all.disable_ipv6`.
---
## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática
**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas
*seguían* sin aparecer en claude.ai. Este era el síntoma original de la
noche.
**Diagnóstico (verificado).** Ambos procesos `claude --remote-control
--continue` estaban corriendo (`ps`), y el healthcheck había recreado
las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
causa:
- Sesión `lab`: `Login expired · Please run /login`
- Sesión `cloud-practice`:
`Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control`
El token OAuth de Claude Code había vencido. No había ningún problema de
red, de contenedor ni de volúmenes. **Esta causa no tenía relación con
ninguna de las tres anteriores.** Eliminar el contenedor nunca podría
haberla resuelto.
**Fix.**
1. `/login` en **una** sesión. Las credenciales viven en
`/root/.claude/.credentials.json`, que es el volumen compartido por
todas las sesiones del contenedor.
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
abierta. Un proceso `claude` que ya estaba corriendo **no** se
reconecta solo al renovarse el token. Hay que reactivarlo en cada uno.
Para una sesión a la que no estás adjunto:
```bash
tmux send-keys -t cloud-practice '/remote-control' Enter
# aparece el menú con "1. Enable Remote Control" ya seleccionado
tmux send-keys -t cloud-practice Enter
```
**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a
mostrar `/remote-control is active · Continue here, on your phone, or at
https://claude.ai/code/session_…`, y en `lab` se activó con
`/remote-control` después del `/login`. Las dos sesiones volvieron a
aparecer en claude.ai.
!!! tip "Diferencia clave con las otras causas"
El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue`
retoma la conversación. Un **token vencido no se recupera solo**: hace
falta que una persona se reautentique (`/login`) y reactive Remote
Control (`/remote-control`) en cada sesión abierta. Recrear el
contenedor no lo arregla. Con los volúmenes bien montados, el
contenedor nuevo lee el mismo token vencido.
---
## Cronología resumida
Horas en UTC, según `health.log`, `docker images` y los timestamps de
archivos cuando existen.
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|---|---|---|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — |
| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No |
| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró |
| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No |
| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No |
| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No |
| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No |
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No |
| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado |
| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** |
**Camino corto para la próxima vez.** Si "no aparecen las sesiones
remotas", **antes de tocar el contenedor** mirar el pane de cada sesión:
```bash
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
```
Si aparece `Login expired` u `OAuth token unavailable`, la solución es
`/login` + `/remote-control`. No hace falta nada más.
## Lección general
Un solo síntoma ("no toma las sesiones remotas") puede tener **varias
causas independientes superpuestas**. En este caso, tres de ellas las
provocó el propio intento de arreglo. Que la primera causa real
encontrada se haya resuelto no significa que era *la* causa del síntoma.
Lo que destrabó el incidente fue seguir verificando con evidencia
concreta después de cada fix, en vez de asumir que "ya quedó": el
contenido de los panes, `health.log`, `docker inspect`, `docker images`
(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó
con un dato observable, no por deducción.
## Pendientes
Todos cerrados el 2026-09-26:
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
`fix-real` en producción.
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
que ahora sí se aplica solo (ver la causa c).
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
y que `disable_ipv6` siga en `1`.
+3
View File
@@ -86,6 +86,7 @@ nav:
- Flujo GitOps: arquitectura/gitops-flujo.md
- Agente de monitoreo: arquitectura/agente-monitoreo.md
- Sesión tmux persistente: arquitectura/sesion-tmux-persistente.md
- Plugins y subagentes: arquitectura/plugins-y-subagentes.md
- Glosario: arquitectura/glosario.md
- Decisiones (ADRs):
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
@@ -100,6 +101,7 @@ nav:
- "0010 · Pago y envío reales": decisiones/0010-pago-envio-reales-y-storefront.md
- "0011 · Ambiente de Staging": decisiones/0011-ambiente-staging.md
- "0012 · Práctica multi-cloud GCP/Azure": decisiones/0012-practica-multicloud-gcp-azure.md
- "0013 · Iniciativas de clientes": decisiones/0013-iniciativas-clientes.md
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -112,6 +114,7 @@ nav:
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
- "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
- Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.274
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.283
# El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
@@ -26,11 +26,11 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.284
env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION
value: v1.0.275
value: v1.0.284
- name: HEADLESS_PROVIDER
value: medusa
# Backend COMPARTIDO con Producción a propósito (decisión del
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
// Galería deslizable de la tarjeta de producto: permite ver todas las
// imágenes desde el listado sin entrar al producto. En móvil se desliza
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
export function ProductCardGallery({
images,
name,
href,
priority = false,
}: {
images: string[];
name: string;
href: string;
priority?: boolean;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const total = images.length;
const goTo = (target: number) => {
const node = scroller.current;
if (!node) return;
const next = Math.max(0, Math.min(total - 1, target));
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
};
const onScroll = () => {
const node = scroller.current;
if (!node || node.clientWidth === 0) return;
setIndex(Math.round(node.scrollLeft / node.clientWidth));
};
return (
<>
<div
ref={scroller}
onScroll={total > 1 ? onScroll : undefined}
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((src, slide) => (
<Link
key={src}
href={href}
prefetch={false}
className="relative block h-full w-full shrink-0 snap-center snap-always"
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
tabIndex={slide === 0 ? 0 : -1}
>
<Image
src={src}
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
fill
priority={priority && slide === 0}
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
className="object-cover transition duration-500 group-hover:scale-[1.035]"
/>
</Link>
))}
</div>
{total > 1 && (
<>
<button
type="button"
aria-label="Imagen anterior"
disabled={index === 0}
onClick={() => goTo(index - 1)}
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Imagen siguiente"
disabled={index === total - 1}
onClick={() => goTo(index + 1)}
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronRight className="size-5" />
</button>
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
{images.map((src, dot) => (
<button
key={src}
type="button"
aria-label={`Ver imagen ${dot + 1} de ${total}`}
aria-current={dot === index}
onClick={() => goTo(dot)}
className="grid h-5 place-items-center px-[3px]"
>
<span
className={`block h-1.5 rounded-full transition-all ${
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
}`}
/>
</button>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -1,9 +1,9 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { ProductCardGallery } from "@/components/product/product-card-gallery";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
@@ -22,6 +22,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null;
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
@@ -37,7 +40,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
return (
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
<div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
<div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
@@ -25,14 +25,14 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.284
env:
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
# que bumpea el tag de la imagen -- el footer la lee
# (process.env.APP_VERSION) para mostrar la versión real
# desplegada en vez de un "v4.1" fijo.
- name: APP_VERSION
value: v1.0.275
value: v1.0.284
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL