Semana 13 — Despliegue y operación

Objetivos

  • Explicar qué es una imagen de contenedor, qué la hace reproducible e inmutable, y escribir un Dockerfile para una aplicación de Node.
  • Distinguir entrega continua de despliegue continuo, y comparar despliegue gradual, blue-green y canary, y las clases de feature flags.
  • Definir SLI, SLO y SLA, calcular un presupuesto de errores y elegir los indicadores de un servicio a partir de lo que ve quien lo usa.
  • Instrumentar un servicio con logs estructurados y un endpoint de salud, y apagarlo sin cortar las solicitudes en curso.
  • Escribir un postmortem sin culpables (blameless) con línea de tiempo, causas y acciones verificables.

Sesión A — Despliegue: contenedores, entornos y feature flags

Lecturas:

Lecturas complementarias:

Temas:

  • Entrega continua: cada cambio que pasa el pipeline queda listo para desplegarse con un botón. Despliegue continuo: además se despliega solo, sin una persona en medio. La diferencia no es técnica sino de confianza en el pipeline. SWEG cap. 24: lo que se busca es que desplegar sea aburrido, frecuente y en cambios pequeños, porque un cambio pequeño es fácil de entender y de revertir.
  • Contenedor: un proceso aislado (sistema de archivos, red, procesos propios) que comparte el kernel del sistema; no es una máquina virtual. Una imagen es el sistema de archivos inicial, construido en capas por las instrucciones del Dockerfile (FROM, COPY, RUN, CMD); cada capa se reutiliza si no cambió lo que entra en ella, así que lo que cambia seguido (el código) va al final.
  • Imágenes inmutables: la misma imagen pasa por staging y producción; lo que cambia entre entornos es la configuración, que entra por variables de entorno (factor III). La imagen se etiqueta con el SHA del commit, no con latest, para saber qué está corriendo y poder volver a la versión anterior. El estado (la base de datos de SQLite) no vive en la imagen ni en la capa escribible del contenedor sino en un volumen: un contenedor se borra y se reemplaza en cada despliegue.
  • Desechabilidad (factor IX): el proceso arranca rápido y se apaga bien. Al recibir SIGTERM deja de aceptar conexiones, termina las solicitudes en curso, cierra la base de datos y sale. Si no maneja la señal, docker stop espera y termina matándolo con SIGKILL (código de salida 137): las solicitudes en curso se cortan.
  • Estrategias de despliegue: rolling (se reemplazan las instancias de a pocas), blue-green (dos entornos completos; se cambia el tráfico de uno a otro y se vuelve atrás con el mismo cambio), canary (la versión nueva recibe primero un porcentaje pequeño del tráfico y se compara con la anterior). Un rollback solo es fácil si el cambio de la base de datos es compatible con la versión anterior (migraciones que agregan, semana 16).
  • Feature flags (Hodgson): separan desplegar (el código está en producción) de lanzar (la gente lo usa). Tipos: de release (esconden algo a medio terminar), de operación (un kill switch para apagar algo caro o roto sin desplegar), de experimento (A/B) y de permisos. Cada flag es una rama más en el código: tiene owner y fecha para retirarse.
  • Error común: guardar en la imagen lo que es configuración o estado: una contraseña en el Dockerfile (queda en una capa aunque una instrucción posterior la borre), o la base de datos dentro del contenedor, que desaparece en el siguiente despliegue.
  • Con IA: un agente que despliega es un agente con acceso a producción. Las mismas reglas que para una persona, más estrictas: el despliegue pasa por el pipeline, con permisos mínimos, revisión y un rollback probado; nunca credenciales de producción en el contexto de un agente.

Sesión B — Operación: observabilidad, SLOs, incidentes y postmortems

Lecturas:

Lecturas complementarias:

Temas:

  • SLI (service level indicator): una medida de lo que vive quien usa el servicio, casi siempre una proporción de eventos buenos sobre eventos válidos. Para Kallpa: proporción de solicitudes a /api/* que no responden 5xx; proporción de reservas que se confirman en menos de 300 ms. SLO: el objetivo interno sobre un SLI en una ventana (“99.5 % de las solicitudes sin 5xx en 30 días”). SLA: un contrato con consecuencias (multas, devoluciones) si no se cumple; conviene que el SLO interno sea más estricto, para tener margen antes de incumplirlo.
  • Presupuesto de errores: lo que el SLO permite fallar. Con 99.5 % en 30 días, 0.5 % de 720 horas son 3.6 horas de indisponibilidad total, o el equivalente en solicitudes fallidas. Mientras hay presupuesto, el equipo despliega; si se acaba, se prioriza la confiabilidad. Un SLO de 100 % es un error: ninguna dependencia lo cumple y congela todo cambio.
  • Las cuatro señales doradas (SRE cap. 6): latencia (separando la de las solicitudes buenas de la de los errores), tráfico, errores y saturación. Se alerta sobre síntomas que ve el usuario (“el SLO se está quemando”), no sobre causas (“CPU al 80 %”).
  • Los tres tipos de telemetría: logs (eventos individuales), métricas (números agregados en el tiempo) y traces (el recorrido de una solicitud por varios servicios). Un log estructurado es una línea JSON con campos fijos (time, level, msg, method, path, status, durationMs, un identificador de la solicitud) que se puede filtrar y agregar con jq o una herramienta de logs; una línea de texto libre solo se puede leer. OpenTelemetry es el estándar abierto para producir los tres.
  • Endpoint de salud: GET /health responde rápido si el proceso puede atender (y, si se quiere, si la base de datos responde). Lo usan el HEALTHCHECK de Docker, el load balancer y las pruebas de humo después de un despliegue. No debe hacer trabajo caro ni requerir autenticación.
  • Incidente: algo que afecta a los usuarios y requiere una respuesta coordinada. Roles mínimos: quien coordina, quien investiga, quien comunica. Lo primero es mitigar (rollback, apagar un flag), después entender. Postmortem sin culpables: qué pasó (línea de tiempo con horas), impacto (en términos del SLI), causas (el proceso que permitió el error, no la persona que se equivocó), qué salió bien, y acciones con owner y fecha. Una acción como “tener más cuidado” no es una acción.
  • Las métricas de DORA miden el desempeño de entrega de un equipo con cinco números: tres de rendimiento (change lead time, deployment frequency y failed deployment recovery time, que reemplazó al antiguo MTTR) y dos de inestabilidad (change fail rate y deployment rework rate). DORA encuentra que velocidad y estabilidad no se contraponen: los mejores equipos mejoran en ambas.
  • Error común: medir lo que es fácil y no lo que vive el usuario. Un servidor que responde 200 a /health mientras todas las reservas fallan está “arriba” según el monitoreo y caído según los vecinos. Con IA pasa algo parecido: una herramienta puede resumir un incidente a partir de los logs estructurados, pero la línea de tiempo que escribe se comprueba contra esos mismos logs, con un jq, línea por línea.

Laboratorio — Desplegar Kallpa, observarla y escribir el postmortem de un incidente simulado

Guía: labs/README.md. Referencias: Node.js, process (señales) y http (server.close); Docker, Dockerfile reference; SRE, cap. 15.

Trabajen en su repositorio de Kallpa, en ramas cortas, con un pull request por paso (el pipeline de la semana 12 los revisa). Los pasos 1 a 3 van en el contenedor del curso; Docker no está dentro del contenedor: los pasos 4 a 7 se corren en la terminal de su computadora, con el puerto 8080 para no chocar con el 3000 de run.sh.

  1. Agregar GET /health, que responda 200 con {"status":"ok"}, con una prueba en test/http.test.ts. Comprobar con make start y, en otra shell del contenedor, curl -s localhost:3000/health.
  2. Logs estructurados: reemplazar la línea de texto de cada solicitud por una línea JSON con time, level, msg, method, path, status y durationMs, sin datos personales, y hacer que un error 500 también salga como JSON, con la ruta que lo causó. Con el servidor escribiendo en un archivo (make start > kallpa.log), hagan algunas solicitudes buenas y malas y calculen un SLI con jq:
    grep '^{' kallpa.log | jq -s 'map(select(.path? // "" | startswith("/api/"))) | {total: length, errores: map(select(.status >= 500)) | length}'
  3. Apagado ordenado: al recibir SIGTERM, dejar de aceptar conexiones (server.close), cerrar la base de datos y salir con código 0. Probarlo desde otra shell del contenedor con pkill -TERM -f 'node src/main.ts' y mirar el log del servidor.
  4. Escribir un Dockerfile (a partir de node:26-trixie-slim, sin herramientas de desarrollo, corriendo como el usuario node y no como root, con un HEALTHCHECK que llame a /health) y un .dockerignore (sin node_modules/, data/ ni .git/). La base de datos va en /app/data, y esa carpeta tiene que pertenecer al usuario node. En su computadora:
    docker build -t kallpa:$(git rev-parse --short HEAD) -t kallpa .
    docker run -d --name kallpa -p 8080:3000 -v kallpa-data:/app/data kallpa
    docker inspect --format '{{.State.Health.Status}}' kallpa   # starting, luego healthy
    curl -s localhost:8080/health
    docker logs kallpa
  5. Comparar el apagado: time docker stop kallpa y docker inspect -f '{{.State.ExitCode}}' kallpa, con su manejo de SIGTERM y sin él (comenten por un momento el manejador, reconstruyan la imagen con otra etiqueta y vuelvan a medir; no hagan commit de eso). Anoten el código de salida de cada una y expliquen la diferencia.
  6. Un feature flag de operación: KALLPA_BOOKINGS_OPEN (por omisión true), leído en src/config.ts. Con false, POST /api/bookings responde 503 con un código de error estable y la disponibilidad se sigue viendo. Probarlo con docker run ... -e KALLPA_BOOKINGS_OPEN=false ..., sin construir otra imagen.
  7. El incidente. code/week-13/incidente.md cuenta lo que vio la administración un viernes después de un despliegue. Reprodúzcanlo: hagan una reserva por la API en el contenedor kallpa, “desplieguen” una versión nueva como lo hizo esa persona (docker rm -f kallpa y un docker run sin -v) y busquen la reserva. Encuentren la causa, mitíguenla y comprueben que con el volumen la reserva sobrevive a un nuevo despliegue.
  8. Escribir el postmortem en docs/postmortems/AAAA-MM-DD-tema.md con el template code/week-13/postmortem-template.md, que tiene las mismas secciones que pide P4: la línea Tipo: (aquí, game day), ## Resumen, ## Impacto (con datos de los logs), ## Línea de tiempo (con horas, incluida la detección), ## Causa raíz (el proceso que lo permitió, sin nombres de personas) y ## Acciones, con al menos tres acciones con owner y fecha: una que evite que se repita (por ejemplo, una prueba de humo en el pipeline) y otra que lo detecte antes.

Entregas

  • No hay entregas esta semana. P4 (projects/p4-entrega-continua/README.md) pide despliegue, observabilidad y el postmortem de un incidente de su proyecto: los pasos de hoy son los mismos. Entrega en semana 14.

Preguntas de repaso

  1. ¿Qué diferencia hay entre entrega continua y despliegue continuo? ¿Qué tendría que ser cierto del pipeline de Kallpa para pasar de una a otra?
  2. Kallpa tiene un SLO de 99.5 % de solicitudes sin 5xx en 30 días y recibe unas 20 000 solicitudes al mes. ¿Cuántas pueden fallar? Si un despliegue malo hace fallar 60 solicitudes en un par de días, ¿qué parte del presupuesto consumió y qué debería hacer el equipo?
  3. ¿Por qué el contenedor de Kallpa sin manejo de SIGTERM termina con código 137? ¿Qué le pasa a una reserva que se estaba guardando en ese momento?
  4. Den un ejemplo de flag de release, uno de operación y uno de experimento para Kallpa. ¿Cuál retirarían primero y cuándo?
  5. En un borrador de postmortem una acción dice “Pedro debe revisar mejor antes de desplegar”. ¿Qué tiene de malo? Reescríbanla como una o dos acciones que funcionen.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (15)Pruebas (5)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint, sin información de tipos.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'solution/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
incidente.mdDescargar incidente.md
# Incidente: "mi reserva no aparece"

Caso escrito para el laboratorio; el complejo, las horas y las personas son
ficticios. Es lo que la administración de Kallpa contó el lunes siguiente.
Los pasos 7 y 8 del laboratorio de la [semana 13](../../weeks/week-13.md) parten de aquí.

## Lo que vio la administración

**Viernes, 17:30.** Quien estaba de turno desplegó una versión nueva: corregía
un texto de la página. El cambio era pequeño y las pruebas pasaban. Desde su
terminal corrió:

```sh
docker build -t kallpa .
docker rm -f kallpa
docker run -d --name kallpa -p 8080:3000 kallpa
```

Hasta el jueves el contenedor se había levantado con un comando guardado en una
nota, en otra computadora. Abrió la página, vio el calendario con reservas y
se fue a su casa.

**Viernes, 18:40.** Llegó un equipo de fulbito con su reserva de las 19:00,
hecha el martes. El vecino mostró el WhatsApp de confirmación. En la página, a
esa hora, la cancha decía *Real Barranco*, un equipo que nadie en el complejo
conocía. La administradora pensó que era un error de ese equipo, le pidió
disculpas y les dio otra cancha.

**Viernes, 19:15.** Dos llamadas más por lo mismo. Una vecina dijo que había
reservado esa misma tarde, a las 17:50, y que ahí su reserva sí estaba.

**Viernes, 20:00.** La administradora abrió la caja del día: los totales no
cuadraban con el cuaderno. Escribió al grupo del equipo de desarrollo.

**Viernes, 20:10.** Quien estaba de turno miró el monitor: `GET /health`
respondía `200` y el contenedor decía `healthy`, como toda la tarde.
Respondió: "el sistema está arriba".

**Lunes.** La administración pide que no vuelva a pasar, y quiere saber a
cuántos vecinos tiene que llamar.

## Qué se les pide

1. **Reproducirlo** en su computadora, con su imagen, como dice el paso 7. Es
   un game day: lo provocan ustedes, a propósito.
2. **Explicar cada síntoma** con lo que ven en su reproducción: por qué aparece
   un equipo que nadie conoce, por qué `/health` dice que todo está bien, y por
   qué nada falló al desplegar.
3. **Mitigar** y comprobar que una reserva sobrevive a un nuevo despliegue.
4. **Responder a la administración:** qué se recupera, qué no y por qué. Piensen
   en dos grupos de vecinos: quienes reservaron antes del viernes a las 17:30 y
   quienes reservaron después. Comprueben su respuesta: hagan una reserva de
   cada tipo, antes de mitigar, y búsquenlas después.
5. **Escribir el postmortem** con [`postmortem-template.md`](postmortem-template.md).
   Las horas de la línea de tiempo y los datos del impacto salen de su propia
   reproducción y de sus logs, no de este relato. Sin nombres de personas.
postmortem-template.mdDescargar postmortem-template.md
# Postmortem: tema del incidente

Tipo: game day

<!--
Copien este archivo en docs/postmortems/AAAA-MM-DD-tema.md y borren estos
comentarios. Tipo: incidente real, si pasó de verdad, o game day, si lo provocó
el equipo a propósito en un entorno controlado. La revisión de P4 pide esa línea
y las cinco secciones de abajo, con estos títulos.

Sin culpables: se escribe sobre el sistema y el proceso. Ni nombres de personas
ni "fue un error humano"; los roles (quien estaba de turno) sí se pueden usar.
-->

## Resumen

<!-- Qué pasó, en tres frases: qué vio quien usa el servicio, por qué y cómo terminó. -->

## Impacto

<!--
A quién afectó, cuánto tiempo y cuántas solicitudes, reservas o usuarios, con
datos de los logs. Digan con qué comando salió cada número. Si tienen un SLO,
qué parte del presupuesto de errores se consumió.
-->

## Línea de tiempo

<!--
Con horas y zona horaria, desde la causa hasta el cierre. No se olviden de marcar
la detección: cuánto pasó entre que empezó y que alguien se enteró.
-->

| Hora | Qué pasó |
|---|---|
| | Empieza |
| | Detección: cómo se enteraron |
| | Mitigación |
| | Cierre |

## Causa raíz

<!--
Por qué pasó y por qué el proceso (pruebas, revisión, monitoreo) no lo detectó
antes. Si la causa es "alguien se equivocó", todavía no llegaron: pregunten qué
hizo posible ese error y por qué nada lo frenó. Puede haber más de una causa.
Agreguen lo que salió bien y lo que tuvo suerte.
-->

## Acciones

<!--
Al menos tres, y al menos una que evite que se repita y otra que lo detecte
antes. Cada una con algo que se pueda comprobar que está hecho, owner y fecha.
"Tener más cuidado" no es una acción.
-->

| Acción | Tipo | Owner | Fecha | Estado |
|---|---|---|---|---|
| | evita | | | pendiente |
| | detecta | | | pendiente |
| | mitiga | | | pendiente |
tools.tsDescargar tools.ts
// Utilidades de las pruebas: correr herramientas y levantar procesos.
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { resolve } from 'node:path';

export const HERE = import.meta.dirname;
export const KALLPA = resolve(HERE, '../../kallpa');

export function run(command: string, args: string[], cwd: string = HERE): { code: number; output: string } {
  // Sin NODE_TEST_CONTEXT: si no, un node --test hijo no corre sus archivos.
  const env = { ...process.env };
  delete env.NODE_TEST_CONTEXT;
  const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' });
  return { code: result.status ?? -1, output: `${result.stdout}${result.stderr}` };
}

export type Started = {
  child: ChildProcessWithoutNullStreams;
  output: () => string;
  exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
};

// Levanta un proceso de Node y espera a que escriba `ready` en stdout.
export async function start(args: string[], env: NodeJS.ProcessEnv, ready: RegExp, cwd: string = HERE): Promise<Started> {
  const childEnv = { ...process.env, ...env };
  delete childEnv.NODE_TEST_CONTEXT;
  const child = spawn(process.execPath, args, { cwd, env: childEnv });
  let text = '';
  child.stdout.on('data', (chunk: Buffer) => (text += chunk.toString()));
  child.stderr.on('data', (chunk: Buffer) => (text += chunk.toString()));
  const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done) => {
    child.once('exit', (code, signal) => done({ code, signal }));
  });
  for (let i = 0; i < 500 && !ready.test(text); i++) {
    await new Promise((wake) => setTimeout(wake, 20));
  }
  assertReady(ready, text);
  return { child, output: () => text, exited };
}

function assertReady(ready: RegExp, text: string): void {
  if (!ready.test(text)) throw new Error(`El proceso no llegó a ${String(ready)}:\n${text}`);
}
ejemplos/2026-11-02-proceso-caido.mdDescargar ejemplos/2026-11-02-proceso-caido.md
# Postmortem: un pedido mal formado apaga Kallpa

Tipo: game day

Lo provocamos con `ejemplos/game-day.sh` en una computadora de desarrollo, con
una imagen de Kallpa que ya tiene `/health` y logs JSON. Las horas son de Lima.
El tráfico es de una solicitud por segundo, generado por el mismo script.

## Resumen

Una sola solicitud con la ruta `//` apagó el proceso de Kallpa, y la página dejó
de responder durante 50 segundos. El contenedor quedó detenido, y nada lo
reinició hasta que una persona corrió `docker start`. No se perdieron datos.

## Impacto

- **Indisponibilidad total:** de 01:49:08 a 01:49:58, 50 segundos.
- **Solicitudes:** fallaron 49 de 79 (`grep -c ' 000$'` sobre el registro de
  llamadas del script), además de la que causó la caída.
- **Presupuesto:** con el SLO de 99.5 % y 20 000 solicitudes al mes, el presupuesto
  es de 100; este episodio gastaría 49. Es una cuenta gruesa: en el mes real no
  llega una solicitud por segundo, y en la noche fallarían menos.
- **Datos:** ninguno perdido. La base de datos está en un volumen y las
  transacciones ya confirmadas no dependen del proceso.

## Línea de tiempo

| Hora | Qué pasó |
|---|---|
| 01:49:08 | Empieza: llega `GET //`. `new URL` lanza un error, el proceso termina con código 1 |
| 01:49:09 | Primera solicitud fallida de los vecinos |
| 01:49:18 | El monitor (`/health` cada 10 s) ve la primera falla |
| 01:49:28 | **Detección:** segunda falla seguida, el monitor avisa, 20 s después del inicio |
| 01:49:58 | Última solicitud fallida |
| 01:49:58 | **Mitigación:** quien está de turno corre `docker start` |
| 01:50:08 | Cierre: termina el ensayo con el contenedor arriba |

## Causa raíz

Inmediata: en `src/http/server.ts`, `new URL(req.url, ...)` está fuera del `try`.
La función del servidor es `async`, así que el error se vuelve una promesa
rechazada que nadie maneja, y Node termina el proceso.

Del proceso:

- **Las pruebas** no mandan rutas mal formadas al servidor: las del router tienen
  rutas válidas, y `new URL` se ejecuta antes de llegar al router.
- **El despliegue** no tiene política de reinicio: un proceso que termina se
  queda terminado. El `HEALTHCHECK` no cubre esto: solo cambia una etiqueta.
- **El monitoreo** detectó en 20 s, pero el aviso dependía de que una persona lo viera.

**Lo que salió bien:** el monitor sí mira algo que ven los usuarios, y `docker logs`
tenía el stack trace completo. **Lo que tuvo suerte:** la base de datos estaba en
un volumen; si esa misma persona hubiera recreado el contenedor con otro comando,
el problema habría sido otro.

## Acciones

| Acción | Tipo | Owner | Fecha | Estado |
|---|---|---|---|---|
| Mover `new URL` dentro del `try` y agregar una prueba que mande `GET //` y compruebe que responde un error 4xx y que el servidor sigue arriba | evita | backend | 2026-11-06 | pendiente |
| Levantar el contenedor con `--restart=on-failure` en el script de despliegue; repetir el game day y comprobar que no fallan más de 2 solicitudes | mitiga | quien despliega | 2026-11-06 | hecho: 0 de 59 fallidas, 1 reinicio |
| Avisar cuando `RestartCount` suba: un reinicio automático esconde el error si nadie lo mira | detecta | operación | 2026-11-13 | pendiente |
ejemplos/apagado.tsDescargar ejemplos/apagado.ts
// Un servidor mínimo para estudiar el apagado. /lenta tarda DELAY_MS en
// responder: es una solicitud en curso. Con GRACEFUL=1 maneja SIGTERM; sin eso,
// se queda con lo que el sistema operativo haga por defecto.
import http from 'node:http';

const DELAY_MS = Number(process.env.DELAY_MS ?? 500);

const server = http.createServer((_req, res) => {
  setTimeout(() => res.end('terminé\n'), DELAY_MS);
});

export function shutdown(deadlineMs: number): void {
  console.log('apagando');
  // Si algo no termina a tiempo, se corta a propósito antes de que llegue SIGKILL.
  const deadline = setTimeout(() => server.closeAllConnections(), deadlineMs);
  deadline.unref();
  // close deja de aceptar conexiones y avisa cuando ya no queda ninguna abierta.
  server.close(() => {
    console.log('apagado');
    process.exitCode = 0;
  });
}

if (process.env.GRACEFUL === '1') {
  process.once('SIGTERM', () => shutdown(8000));
}

server.listen(Number(process.env.PORT ?? 0), '127.0.0.1', () => {
  const { port } = server.address() as { port: number };
  console.log(`escuchando ${port}`);
});
ejemplos/Dockerfile.ingenuoDescargar ejemplos/Dockerfile.ingenuo
# Un primer intento que funciona y tiene cinco problemas: los cuenta el apunte.
FROM node:26-trixie-slim
COPY . /app
WORKDIR /app
CMD node src/main.ts
ejemplos/game-day.shDescargar ejemplos/game-day.sh
#!/bin/bash
# Un game day chico: se cae el proceso de Kallpa a propósito y se mide cuánto se
# nota. Corre en la terminal de su computadora (necesita Docker y una imagen con
# GET /health; Kallpa escucha en el puerto 8080). No toca el volumen de nadie:
# usa uno propio, kallpa-gameday, y lo borra al final.
#
#   game-day.sh IMAGEN                     nadie reinicia: el monitor avisa y una
#                                          persona corre docker start
#   game-day.sh IMAGEN --restart=on-failure  Docker reinicia el proceso
IMAGE=${1:?Falta la imagen}
POLICY=${2:-}
NAME=kallpa-gameday
PORT=8080
CALLS=$(mktemp)

docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -p "$PORT:3000" -v kallpa-gameday:/app/data $POLICY "$IMAGE" >/dev/null
sleep 2

# El tráfico: una solicitud por segundo, como un vecino que recarga la página.
(
  while true; do
    echo "$(date +%T) $(curl -s -m 2 -o /dev/null -w '%{http_code}' "localhost:$PORT/api/courts")" >> "$CALLS"
    sleep 1
  done
) &
TRAFFIC=$!

sleep 20
echo "$(date +%T) se manda el pedido que tumba el proceso"
curl -s --path-as-is -o /dev/null "localhost:$PORT//"

# El monitor: revisa /health cada 10 s y avisa a la segunda falla seguida. Si
# Docker reinicia el proceso, nunca llega a avisar.
FAILS=0
for _ in 1 2 3 4; do
  sleep 10
  if curl -s -m 2 -o /dev/null -f "localhost:$PORT/health"; then
    FAILS=0
  else
    FAILS=$((FAILS + 1))
    echo "$(date +%T) el monitor ve /health caído ($FAILS)"
  fi
  if [ "$FAILS" -eq 2 ]; then
    echo "$(date +%T) el monitor avisa a quien está de turno"
    sleep 30   # lo que tarda una persona en ver el aviso, leer el log y reiniciar
    echo "$(date +%T) docker start"
    docker start "$NAME" >/dev/null
    sleep 10
    break
  fi
done

kill "$TRAFFIC"
wait "$TRAFFIC" 2>/dev/null
echo "$(date +%T) fin del ensayo"
docker inspect -f 'estado={{.State.Status}} reinicios={{.RestartCount}}' "$NAME"
echo "solicitudes: $(wc -l < "$CALLS" | tr -d ' '), fallidas: $(grep -c ' 000$' "$CALLS")"
echo "primera falla: $(grep -m1 ' 000$' "$CALLS"), última falla: $(grep ' 000$' "$CALLS" | tail -1)"
docker logs "$NAME" 2>&1 | grep -c '"msg":"listening"' | sed 's/^/arranques en el log: /'
docker rm -f "$NAME" >/dev/null
docker volume rm kallpa-gameday >/dev/null
rm -f "$CALLS"
ejemplos/kallpa-muestra.txtDescargar ejemplos/kallpa-muestra.txt
node src/main.ts
{"time":"2026-11-02T06:15:15.864Z","level":"warn","msg":"new database, sample data loaded","dbPath":"data/kallpa.db"}
{"time":"2026-11-02T06:15:15.867Z","level":"info","msg":"listening","port":3000}
{"time":"2026-11-02T06:15:17.202Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":1}
{"time":"2026-11-02T06:15:17.209Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":1}
{"time":"2026-11-02T06:15:17.213Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.217Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.222Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.226Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.231Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.236Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.241Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.246Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.252Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.256Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.261Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.265Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.269Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.274Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.278Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.283Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.287Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.291Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.295Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.300Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.304Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.308Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.313Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.317Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.322Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.327Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.331Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.336Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.340Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.345Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.350Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.354Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.358Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.362Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.366Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.371Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.375Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.379Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.384Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.388Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.392Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.396Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.401Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.406Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.410Z","level":"info","msg":"request","method":"GET","path":"/","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.414Z","level":"info","msg":"request","method":"GET","path":"/api/courts","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.418Z","level":"info","msg":"request","method":"GET","path":"/api/courts/1/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.423Z","level":"info","msg":"request","method":"GET","path":"/api/courts/3/availability","status":200,"durationMs":0}
{"time":"2026-11-02T06:15:17.429Z","level":"info","msg":"request","method":"GET","path":"/health","status":200,"durationMs":1}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 7:00 a 8:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.440Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":6}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 8:00 a 9:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.448Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":3}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 9:00 a 10:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.460Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":6}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 10:00 a 11:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.469Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":5}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 11:00 a 12:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.477Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":3}
[whatsapp] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 2 el 2026-11-05 de 12:00 a 13:00 está confirmada. Total: S/ 90.00.
{"time":"2026-11-02T06:15:17.486Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":200,"durationMs":4}
{"time":"2026-11-02T06:15:17.491Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":409,"durationMs":0}
{"time":"2026-11-02T06:15:17.496Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":409,"durationMs":0}
{"time":"2026-11-02T06:15:17.500Z","level":"info","msg":"request","method":"POST","path":"/api/bookings","status":422,"durationMs":0}
{"time":"2026-11-02T06:15:17.504Z","level":"info","msg":"request","method":"GET","path":"/api/courts/99/availability","status":400,"durationMs":0}
{"time":"2026-11-02T06:15:17.508Z","level":"info","msg":"request","method":"GET","path":"/no-existe","status":404,"durationMs":0}
{"time":"2026-11-02T06:15:17.512Z","level":"error","msg":"unhandled error","method":"GET","path":"/api/courts/%ZZ/availability","error":"URI malformed"}
{"time":"2026-11-02T06:15:17.512Z","level":"info","msg":"request","method":"GET","path":"/api/courts/%ZZ/availability","status":500,"durationMs":0}
{"time":"2026-11-02T06:15:17.516Z","level":"error","msg":"unhandled error","method":"GET","path":"/api/courts/%ZZ/availability","error":"URI malformed"}
{"time":"2026-11-02T06:15:17.516Z","level":"info","msg":"request","method":"GET","path":"/api/courts/%ZZ/availability","status":500,"durationMs":0}
{"time":"2026-11-02T06:15:17.521Z","level":"error","msg":"unhandled error","method":"GET","path":"/api/members/%ZZ/bookings","error":"URI malformed"}
{"time":"2026-11-02T06:15:17.521Z","level":"info","msg":"request","method":"GET","path":"/api/members/%ZZ/bookings","status":500,"durationMs":0}
{"time":"2026-11-02T06:15:17.523Z","level":"info","msg":"shutting down","signal":"SIGTERM"}
{"time":"2026-11-02T06:15:17.523Z","level":"info","msg":"stopped"}
make salio con 0
ejemplos/presupuesto.tsDescargar ejemplos/presupuesto.ts
// Las cuentas del presupuesto de errores. Un SLO se escribe como fracción
// (99.5 % es 0.995).

// Lo que el SLO permite fallar, como fracción de los eventos.
export function errorBudget(slo: number): number {
  return Number((1 - slo).toFixed(10));
}

// Horas de indisponibilidad total que caben en la ventana.
export function budgetHours(slo: number, windowDays: number): number {
  return Number((errorBudget(slo) * windowDays * 24).toFixed(6));
}

// Solicitudes que pueden fallar si se esperan `expected` en la ventana.
export function budgetEvents(slo: number, expected: number): number {
  return Math.round(errorBudget(slo) * expected);
}

// Qué parte del presupuesto consumió un episodio (1 es todo).
export function budgetSpent(slo: number, expected: number, failed: number): number {
  return failed / (errorBudget(slo) * expected);
}

// La velocidad de quemado: con 1, el presupuesto se acaba justo al final de la
// ventana. Es la tasa de error observada dividida entre la permitida.
export function burnRate(slo: number, errorRate: number): number {
  return errorRate / errorBudget(slo);
}

// Horas hasta quemar todo el presupuesto a una velocidad constante.
export function hoursToExhaust(rate: number, windowDays: number): number {
  return (windowDays * 24) / rate;
}
ejemplos/sli.jqDescargar ejemplos/sli.jq
# El SLI de disponibilidad: solicitudes a /api/* que no responden 5xx.
map(select(.msg == "request" and (.path | startswith("/api/"))))
| { validas: length, buenas: (map(select(.status < 500)) | length) }
| . + { sli: (.buenas / .validas) }
ejemplos/trafico.shDescargar ejemplos/trafico.sh
#!/bin/bash
# Manda a un servidor de Kallpa una mezcla de solicitudes buenas y malas, para
# tener algo que medir en el log. Uso: trafico.sh [url]   (localhost:3000 por omisión)
BASE=${1:-http://localhost:3000}
DAY=$(TZ=America/Lima date -d '+3 days' +%F)
get() { curl -s -o /dev/null "$BASE$1"; }
book() {
  curl -s -o /dev/null -X POST "$BASE/api/bookings" -H 'content-type: application/json' \
    -d "{\"courtId\":$1,\"memberId\":$2,\"date\":\"$DAY\",\"startHour\":$3,\"hours\":1,\"teamName\":\"Equipo $3\"}"
}
for round in 1 2 3 4 5 6 7 8 9 10; do
  get /
  get /api/courts
  get "/api/courts/1/availability?date=$DAY"
  get "/api/courts/3/availability?date=$DAY"
  get /health
done
for hour in 7 8 9 10 11 12; do book 2 1 "$hour"; done   # reservas buenas
book 2 1 7                                              # 409: la hora ya está tomada
book 2 1 7                                              # 409 otra vez
book 2 1 23                                             # 422: fuera de horario
get /api/courts/99/availability                         # 400: falta la fecha
get /no-existe                                          # 404
get /api/courts/%ZZ/availability                        # 500: un error nuestro
get /api/courts/%ZZ/availability
get /api/members/%ZZ/bookings
ejemplos/transaccion-cortada.tsDescargar ejemplos/transaccion-cortada.ts
// Muere con SIGKILL en medio de una transacción, o justo después de confirmarla.
// Uso: transaccion-cortada.ts ARCHIVO [commit]
import { DatabaseSync } from 'node:sqlite';

const [path, mode] = process.argv.slice(2);
const db = new DatabaseSync(path ?? ':memory:');
db.exec('CREATE TABLE IF NOT EXISTS bookings (team TEXT NOT NULL)');
db.exec('BEGIN');
db.prepare('INSERT INTO bookings (team) VALUES (?)').run('Real Barranco');
if (mode === 'commit') db.exec('COMMIT');
process.kill(process.pid, 'SIGKILL');
ejemplos/secreto/credenciales.txtDescargar ejemplos/secreto/credenciales.txt
DB_PASSWORD=contrasena-de-ejemplo
ejemplos/secreto/DockerfileDescargar ejemplos/secreto/Dockerfile
# Un error a propósito, para ver que borrar un archivo no lo saca de la imagen:
# queda en la capa del COPY. Nunca hagan esto con una credencial de verdad.
FROM node:26-trixie-slim
WORKDIR /app
COPY credenciales.txt .
RUN rm credenciales.txt

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

apagado.test.tsDescargar apagado.test.ts
// Lo que dice el apunte sobre SIGTERM, server.close y SIGKILL. Aquí node no es el
// proceso 1: sin manejador, SIGTERM lo termina en el acto. En un contenedor
// (proceso 1) lo ignora: esa parte solo se ve con Docker, en la computadora.
import assert from 'node:assert/strict';
import http from 'node:http';
import { test } from 'node:test';
import { run, start } from './tools.ts';

const READY = /escuchando (\d+)/;

function portOf(text: string): number {
  return Number(READY.exec(text)?.[1]);
}

test('sin manejador, SIGTERM mata el proceso y corta la solicitud en curso', async () => {
  const server = await start(['ejemplos/apagado.ts'], { DELAY_MS: '500' }, READY);
  const inFlight = fetch(`http://127.0.0.1:${portOf(server.output())}/lenta`);
  await new Promise((wake) => setTimeout(wake, 100));
  server.child.kill('SIGTERM');
  await assert.rejects(inFlight);
  assert.deepEqual(await server.exited, { code: null, signal: 'SIGTERM' });
});

test('con manejador, termina la solicitud en curso, rechaza las nuevas y sale con 0', async () => {
  const server = await start(['ejemplos/apagado.ts'], { DELAY_MS: '500', GRACEFUL: '1' }, READY);
  const port = portOf(server.output());
  const inFlight = fetch(`http://127.0.0.1:${port}/lenta`, { headers: { connection: 'close' } });
  await new Promise((wake) => setTimeout(wake, 100));
  server.child.kill('SIGTERM');
  await new Promise((wake) => setTimeout(wake, 100));
  await assert.rejects(fetch(`http://127.0.0.1:${port}/nueva`));
  assert.equal(await (await inFlight).text(), 'terminé\n');
  assert.deepEqual(await server.exited, { code: 0, signal: null });
  assert.match(server.output(), /apagando\napagado/);
});

test('una conexión keep-alive que estaba ocupada retrasa el cierre varios segundos', async () => {
  const server = await start(['ejemplos/apagado.ts'], { DELAY_MS: '200', GRACEFUL: '1' }, READY);
  const agent = new http.Agent({ keepAlive: true });
  const answered = new Promise<void>((done) => {
    http.get({ port: portOf(server.output()), host: '127.0.0.1', path: '/lenta', agent }, (res) => {
      res.resume();
      res.on('end', done);
    });
  });
  await new Promise((wake) => setTimeout(wake, 50));
  const sent = performance.now();
  server.child.kill('SIGTERM');
  await answered;
  const answeredAfter = performance.now() - sent;
  await server.exited;
  const exitedAfter = performance.now() - sent;
  agent.destroy();
  assert.ok(answeredAfter < 1000, `respondió en ${answeredAfter} ms`);
  assert.ok(exitedAfter > 4000, `salió en ${exitedAfter} ms`);
});

test('SIGKILL en medio de una transacción no deja la reserva a medias', () => {
  const file = `build/transaccion-${process.pid}.db`;
  run('mkdir', ['-p', 'build']);
  const count = () => run('sqlite3', [file, 'SELECT COUNT(*) FROM bookings']).output.trim();

  assert.equal(run(process.execPath, ['ejemplos/transaccion-cortada.ts', file]).code, -1);
  assert.equal(count(), '0');
  assert.equal(run(process.execPath, ['ejemplos/transaccion-cortada.ts', file, 'commit']).code, -1);
  assert.equal(count(), '1');
  run('rm', ['-f', file, `${file}-journal`]);
});
caida.test.tsDescargar caida.test.ts
// La Kallpa publicada: una ruta `//` apaga el proceso, y un %ZZ da 500 sin apagarlo.
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import net from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, test } from 'node:test';
import { KALLPA, start } from './tools.ts';

const dir = mkdtempSync(join(tmpdir(), 'kallpa-caida-'));
after(() => rmSync(dir, { recursive: true, force: true }));

async function kallpa(port: number, name: string) {
  return start(['src/main.ts'], { PORT: String(port), KALLPA_DB: join(dir, name) }, /escuchando/, KALLPA);
}

// Por un socket y no con fetch: así se ve que la conexión se corta sin respuesta.
function rawGet(port: number, path: string): Promise<string> {
  return new Promise((resolve) => {
    let text = '';
    const socket = net.connect(port, '127.0.0.1', () => socket.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`));
    socket.on('data', (chunk) => (text += chunk.toString()));
    socket.on('close', () => resolve(text));
    socket.on('error', () => resolve(text));
  });
}

test('GET // apaga el proceso con código 1 y la conexión se corta sin respuesta', async () => {
  const server = await kallpa(38131, 'a.db');
  assert.equal(await rawGet(38131, '//'), '');
  assert.deepEqual(await server.exited, { code: 1, signal: null });
  assert.match(server.output(), /TypeError: Invalid URL/);
});

test('GET /api/courts/%ZZ/availability da 500 y el proceso sigue vivo', async () => {
  const server = await kallpa(38132, 'b.db');
  assert.match(await rawGet(38132, '/api/courts/%ZZ/availability'), /^HTTP\/1\.1 500/);
  assert.match(await rawGet(38132, '/api/courts'), /^HTTP\/1\.1 200/);
  server.child.kill('SIGTERM');
  assert.equal((await server.exited).signal, 'SIGTERM');
});

test('la Kallpa publicada todavía no tiene /health', async () => {
  const server = await kallpa(38133, 'c.db');
  assert.match(await rawGet(38133, '/health'), /^HTTP\/1\.1 404/);
  server.child.kill('SIGTERM');
  await server.exited;
});
logs.test.tsDescargar logs.test.ts
// El log de ejemplo y los jq del apunte: salió de una corrida real de Kallpa con
// logs JSON, con ejemplos/trafico.sh.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { run } from './tools.ts';

const LOG = 'ejemplos/kallpa-muestra.txt';

function jq(filter: string): string {
  const { code, output } = run('sh', ['-c', `grep '^{' ${LOG} | ${filter}`]);
  assert.equal(code, 0, output);
  return output.trim();
}

test('el conteo de la guía cuenta todas las líneas JSON', () => {
  assert.equal(jq(`jq -s -c '{total: length, errores: map(select(.status >= 500)) | length}'`), '{"total":72,"errores":3}');
});

test('hay 65 solicitudes entre las 72 líneas', () => {
  assert.equal(jq(`jq -r 'select(.msg == "request") | .status' | wc -l`), '65');
});

test('el SLI de /api/* da 40 de 43', () => {
  const { validas, buenas, sli } = JSON.parse(jq(`jq -s -c -f ejemplos/sli.jq`)) as { validas: number; buenas: number; sli: number };
  assert.equal(validas, 43);
  assert.equal(buenas, 40);
  assert.ok(Math.abs(sli - 0.9302) < 0.0001);
});

test('los 5xx salen con su ruta, y los 4xx no cuentan como errores nuestros', () => {
  assert.equal(jq(`jq -r 'select(.status >= 500) | .path' | sort -u | wc -l`), '2');
  assert.equal(jq(`jq -c 'select(.status >= 400 and .status < 500)' | wc -l`), '5');
});

test('ninguna línea JSON lleva datos personales; las del aviso por WhatsApp sí', () => {
  const lines = readFileSync(new URL(LOG, new URL('./', import.meta.url)), 'utf8').split('\n');
  const json = lines.filter((line) => line.startsWith('{'));
  const whatsapp = lines.filter((line) => line.startsWith('[whatsapp]'));
  assert.ok(json.every((line) => !/987654321|Rosa/.test(line)));
  assert.equal(whatsapp.length, 6);
  assert.ok(whatsapp.every((line) => /987654321/.test(line)));
});
materiales.test.tsDescargar materiales.test.ts
// El material del laboratorio: el template y el ejemplo pasan la regla de P4, y
// el incidente cuenta síntomas, no la causa.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { repo, runRule } from '../../projects/check/test/helpers.ts';

const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');

for (const file of ['postmortem-template.md', 'ejemplos/2026-11-02-proceso-caido.md']) {
  test(`${file} pasa P4-POSTMORTEM`, async () => {
    const outcome = await runRule('P4-POSTMORTEM', repo({ 'docs/postmortems/2026-06-01-x.md': read(file) }));
    assert.equal(outcome.status, 'ok', JSON.stringify(outcome));
  });
}

test('el template tiene una tabla de acciones con owner y fecha', () => {
  assert.match(read('postmortem-template.md'), /\| Acción \| Tipo \| Owner \| Fecha \| Estado \|/);
});

test('cada acción del ejemplo tiene fecha', () => {
  const example = read('ejemplos/2026-11-02-proceso-caido.md');
  const actions = example.slice(example.indexOf('## Acciones')).split('\n').filter((line) => line.startsWith('| ') && !line.startsWith('| Acción'));
  assert.ok(actions.length >= 3);
  for (const action of actions) assert.match(action, /\| 2026-\d\d-\d\d \|/);
});

test('el incidente cuenta los comandos y los síntomas sin nombrar la causa', () => {
  const incident = read('incidente.md');
  assert.match(incident, /docker run -d --name kallpa -p 8080:3000 kallpa\n/);
  assert.match(incident, /Real Barranco/);
  assert.doesNotMatch(incident, /volumen|capa escribible/i);
});

test('el ejemplo de la imagen ingenua y el del secreto existen', () => {
  assert.match(read('ejemplos/Dockerfile.ingenuo'), /^CMD node src\/main\.ts$/m);
  assert.match(read('ejemplos/secreto/Dockerfile'), /^RUN rm credenciales\.txt$/m);
});
presupuesto.test.tsDescargar presupuesto.test.ts
// Las cuentas de la sección de SLOs.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { budgetEvents, budgetHours, budgetSpent, burnRate, errorBudget, hoursToExhaust } from './ejemplos/presupuesto.ts';

test('99.5 % en 30 días deja 3.6 horas de indisponibilidad total', () => {
  assert.equal(errorBudget(0.995), 0.005);
  assert.equal(budgetHours(0.995, 30), 3.6);
});

test('cada nueve más divide el presupuesto entre diez', () => {
  assert.equal(budgetHours(0.99, 30), 7.2);
  assert.equal(budgetHours(0.999, 30), 0.72);
  assert.equal(budgetHours(0.9999, 30), 0.072);
});

test('con 20 000 solicitudes al mes pueden fallar 100; 150 errores son 1.5 presupuestos', () => {
  assert.equal(budgetEvents(0.995, 20000), 100);
  assert.equal(budgetSpent(0.995, 20000, 150), 1.5);
  assert.equal(budgetSpent(0.995, 20000, 49), 0.49);
});

test('la velocidad de quemado', () => {
  assert.equal(burnRate(0.995, 0.005), 1);
  assert.equal(burnRate(0.995, 0.05), 10);
  assert.equal(hoursToExhaust(1, 30), 720);
  assert.equal(hoursToExhaust(10, 30), 72);
  assert.equal(hoursToExhaust(200, 30), 3.6);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 13: despliegue y operación. Se usa dentro del contenedor del curso, salvo
# los ejemplos con Docker, que corren en la terminal de su computadora.
#
#   make           copia Kallpa en build/kallpa, para probar sin tocar la original
#   make test      revisa los tipos (tsc), corre ESLint y las pruebas (*.test.ts),
#                  que comprueban lo que dice el apunte, y las del laboratorio si
#                  está la carpeta solution/
#   make clean     borra build/
#
# Para el laboratorio: incidente.md y postmortem-template.md.
# Con Docker, en su computadora: ejemplos/game-day.sh, ejemplos/secreto/ y
# ejemplos/Dockerfile.ingenuo.

KALLPA ?= ../../kallpa

.PHONY: all test clean

all: build/kallpa

build/kallpa:
	mkdir -p build
	cp -r $(KALLPA) $@
	rm -f $@/data/*.db $@/data/*.db-*

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"
	@if [ -d solution ]; then node --test "solution/*.test.ts"; fi

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-13",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["*.ts", "ejemplos"],
  "exclude": ["build", "solution"]
}