Volver a proyectos

Hangar

Caso de estudio · Proyecto de aprendizaje autoalojado

De un repositorio Git a una aplicación desplegada.

Una plataforma autoalojada que reúne construcción, ejecución, routing y seguimiento de despliegues. Go y Temporal coordinan el trabajo; Next.js permite iniciarlo y seguir sus logs.

  • Go / Fiber
  • Next.js 14
  • Temporal.io
  • Docker
  • Redis Pub/Sub
  • WebSockets
  • PostgreSQL
  • Traefik
Explorar código

Demo pública no disponible

Coordinar lo que ocurre después de escribir el código

Desplegar requiere conectar varias operaciones: obtener código, construir una imagen, arrancar el contenedor y darle una ruta de acceso. Hangar intenta reunirlas en un proceso que pueda seguirse desde una interfaz y cuyo estado no dependa de mantener abierta una petición HTTP.

El ADR plantea conservar el control de la infraestructura y poder retomar workflows tras reinicios. Es el objetivo de diseño; aquí se explica la implementación disponible, sin atribuirle uso comercial ni disponibilidad comprobada.

El README lo plantea como una alternativa de aprendizaje para desarrolladores que quieren controlar su infraestructura de despliegue sin incorporar Kubernetes. No se presenta como un servicio con usuarios o clientes acreditados.

De la solicitud al contenedor

El dashboard crea una aplicación con nombre y URL Git. Al solicitar el despliegue, la API guarda un registro pending e inicia DeployWorkflow en Temporal; responde 202 sin esperar a que termine el build. El worker clona, construye y arranca. Traefik sirve el subdominio configurado, mientras los logs llegan al navegador por otro canal.

El recorrido de un despliegue
  1. clone

    Worker Go · CloneRepository: obtiene Git y busca el Dockerfile.

  2. build

    Worker Go · BuildDockerImage: construye con Docker Engine.

  3. run

    Worker Go · RunContainer: crea el contenedor con red y límites.

  4. route

    Traefik: aplica las etiquetas y red configuradas en RunContainer.

Secuencia conceptual del código. El routing forma parte de RunContainer; no es una actividad independiente de Temporal.

Un plano para desplegar; otro para servir

El dashboard solicita el despliegue a la API. La API registra la operación en PostgreSQL e inicia el workflow en Temporal. Un worker ejecuta las actividades que interactúan con Git y Docker.

Solicitud de despliegue
  1. Usuario

    Inicia el despliegue

  2. Next.js

    Dashboard

  3. Go / Fiber

    API REST

  4. Temporal

    Workflow y worker

  5. Docker

    Build y contenedor

Acceso a la aplicación

  1. Petición HTTP

    Subdominio de la app

  2. Traefik

    Regla Host en etiquetas Docker

  3. Contenedor

    Aplicación desplegada

Dos recorridos distintos: Temporal coordina el despliegue; Traefik dirige el tráfico hacia la aplicación. Los logs vuelven al dashboard por un canal separado.

Estado persistente

PostgreSQL relaciona aplicaciones, despliegues y logs. Redis Pub/Sub comunica los nuevos mensajes entre las actividades y el handler WebSocket.

Separar la coordinación del trabajo

DeployWorkflow invoca las actividades en orden y espera sus resultados. Las operaciones de disco, red y Docker quedan fuera del workflow: esa separación permite mantener el determinismo que requiere Temporal.

  1. pending

    Registro creado

  2. running

    Actividades en ejecución

  3. success / failed

    Resultado del workflow

Política configurada por actividad

Máximo de intentos
3
Timeout por intento
10 min
Espera inicial entre intentos
5 s
Factor de backoff
2

Valores del código, no mediciones de rendimiento.

Si CloneRepository, BuildDockerImage o RunContainer devuelve un error tras sus reintentos, el workflow intenta marcar failed y retorna el error. No hay rollback automático del conjunto: un contenedor o una red creados antes del fallo pueden requerir limpieza. Si falla la primera actualización a running, retorna el error sin esa transición a failed.

Reintentos por actividad

Temporal configura timeout y reintentos antes de invocar las actividades. Son valores de configuración, no tiempos de despliegue medidos.

Archivo fuente: api/internal/workflows/deploy_workflow.goGo · líneas 36–44
	ao := workflow.ActivityOptions{
		StartToCloseTimeout: 10 * time.Minute,
		RetryPolicy: &temporal.RetryPolicy{
			MaximumAttempts:    3,
			InitialInterval:    5 * time.Second,
			BackoffCoefficient: 2.0,
		},
	}
	ctx = workflow.WithActivityOptions(ctx, ao)

Terminar el workflow no valida la URL

RunContainer registra una advertencia si no logra conectar con la red de Traefik y aun así retorna sin error. El estado success no equivale a una comprobación HTTP de la aplicación. Los errores al guardar el estado final también se ignoran en esta versión.

Separación por aplicación, no por identidad

La unidad de separación del código es la aplicación: identificador, subdominio, contenedor y red bridge propios. Las rutas revisadas no incorporan autenticación de usuarios y el esquema no define propietarios ni organizaciones. La autenticación aparece como pendiente en el README; no se presenta multi-tenancy de usuarios como implementado.

Red propia + red compartida
  • Aplicación A

    Red bridge de A

  • Aplicación B

    Red bridge de B

  • Red compartida de Traefik

    Ambos contenedores también se conectan aquí

Esquema de dos aplicaciones para explicar la topología, no clientes ni despliegues reales.

Límites por contenedor

Memoria
128 MiB
Cuota de CPU
0.25 CPU

Configuración de Docker en RunContainer; no demuestra aislamiento bajo carga.

Recursos y red en la creación del contenedor

Estos argumentos de ContainerCreate fijan memoria y CPU, y conectan el contenedor a la red de la aplicación. La red compartida de Traefik se añade después.

Archivo fuente: api/internal/activities/deploy_activities.goGo · líneas 185–197
		&containertypes.HostConfig{
			Resources: containertypes.Resources{
				Memory:    128 * 1024 * 1024, // 128 MB
				CPUPeriod: 100000,
				CPUQuota:  25000,             // 25% de un core
			},
			RestartPolicy: containertypes.RestartPolicy{Name: "unless-stopped"},
		},
		&networktypes.NetworkingConfig{
			EndpointsConfig: map[string]*networktypes.EndpointSettings{
				appNetwork: {},
			},
		},

La red compartida importa

Crear una red por aplicación no demuestra que los tenants no puedan comunicarse entre sí cuando también comparten otra red. El código revisado no basta para afirmar aislamiento lateral completo.

Logs de despliegue, del worker al navegador

Las actividades registran mensajes del pipeline y la salida de docker build. El helper intenta guardarlos en PostgreSQL y publicarlos en Redis. Son logs del despliegue; el código revisado no implementa un stream general de stdout de la aplicación en ejecución.

Mensajes nuevos
  1. Actividades

    Mensajes y salida del build

  2. Redis Pub/Sub

    Canal por despliegue

  3. WebSocket

    Handler en Go / Fiber

  4. Dashboard

    Vista de logs en Next.js

Al abrir la conexión

  1. PostgreSQL

    Historial del despliegue

  2. WebSocket

    Envía primero los registros guardados

  3. Dashboard

    Recibe el historial antes de los mensajes nuevos

El backend comprueba periódicamente el estado del despliegue y envía done cuando termina. El navegador recibe los mensajes mediante WebSocket.

La entrega sin pérdidas sigue pendiente

El handler lee el historial antes de suscribirse a Redis: existe una ventana entre ambos pasos. Además, el helper ignora errores de escritura y publicación. Este flujo no permite prometer entrega sin pérdidas ni recuperación automática de la conexión.

Suscripción a logs por despliegue

El handler se suscribe al canal del deployment y prepara la consulta periódica de estado. El historial ya se ha leído antes: esta secuencia no garantiza una transición sin pérdidas.

Archivo fuente: api/internal/handler/ws.goGo · líneas 90–105
	// -- 2. Suscribe al canal Redis para logs en vivo --------------------
	channel := rdb.LogChannel(deploymentID)
	pubsub := h.redis.Subscribe(ctx, channel)
	defer pubsub.Close()

	h.log.Info("suscrito a redis",
		zap.String("channel", channel),
		zap.String("deployment_id", deploymentID),
	)

	// -- 3. Reenvía mensajes en tiempo real ----------------------------─
	msgChan := pubsub.Channel()

	// Ticker para verificar si el deployment terminó
	// (por si el mensaje de "done" se perdió)
	statusTicker := time.NewTicker(3 * time.Second)

Rendimiento

Decisiones del código que evitan esperas y trabajo innecesario. Son características de la implementación, no mediciones: no hay benchmarks publicados.

  1. Cola de Temporal en lugar de esperar el build

    TriggerDeploy inicia DeployWorkflow en la cola hangar-deploy y responde 202 de inmediato; el worker hace clone, build y run en segundo plano.

  2. Logs en streaming, línea por línea

    La salida de docker build se decodifica como flujo JSON con json.Decoder; cada línea se guarda en deploy_logs y se publica en el canal Redis hangar:logs:{id} sin esperar a que termine el build.

  3. Historial primero, luego en vivo

    El WebSocket envía los logs guardados y después se suscribe al canal de Redis; si el despliegue ya terminó, cierra tras el historial.

  4. Contexto de build sin archivo temporal

    El tar del contexto se genera en una tubería (io.Pipe) mientras Docker lo lee, y omite la carpeta .git.

  5. Clonado superficial

    CloneRepository usa git clone --depth=1: descarga solo el último commit.

  6. Consultas indexadas y pool de conexiones

    Índices en deployments(app_id) y deploy_logs(deployment_id), las columnas por las que se filtra; pgxpool reutiliza las conexiones a PostgreSQL.

Decisiones con un coste explícito

Temporal para coordinar actividades

Motivo
El ADR busca conservar el progreso ante reinicios. El workflow separa la coordinación de las operaciones con Git, Docker y base de datos.
Alternativa documentada
BullMQ o cron, alternativas que el ADR compara explícitamente.
Compromiso
Añade un servicio Temporal y un worker. El workflow debe ser determinista; su recuperación tras un reinicio no se probó aquí.

Red bridge propia + conexión a Traefik

Motivo
RunContainer crea una red por aplicación y conecta después el contenedor a la red compartida del proxy.
Alternativa documentada
Una sola red compartida, o una red por app con Internal: true; ambas aparecen en el ADR de aislamiento.
Compromiso
Hay que limpiar contenedores y redes al eliminar la app. La conexión compartida de Traefik no permite dar por probado el aislamiento lateral entre apps.

Pruebas que cruzan los límites del sistema

El repositorio incluye tests de integración que llaman a la API y consultan PostgreSQL durante un despliegue. Requieren API, worker, Docker, PostgreSQL, Redis, Temporal y un repositorio local de prueba con Dockerfile.

Salud y creación de apps
Respuesta de health, conexión a PostgreSQL, identificador, subdominio y estado inicial.
Flujo de despliegue
Espera success en la base de datos y busca mensajes del pipeline. No realiza una petición HTTP a la app desplegada.
Red Docker
Consulta su existencia si Docker expone la API TCP; si no está disponible, omite esa comprobación. No prueba bloqueo de tráfico entre apps.
Colisión y app inexistente
Compara los subdominios de dos apps con el mismo nombre y espera un 404 al desplegar un identificador inexistente.

NO VERIFICADO · Ejecución de la suite de Hangar

La suite requiere servicios activos y un repositorio de prueba local con Dockerfile. Ese fixture no está presente en este entorno; no se levantó la infraestructura ni se generó una salida de tests ficticia.

Comando documentado, no ejecutado en esta revisión: go test ./tests/... -v -timeout 120s -tags integration

Lo verificable aquí es la implementación y la existencia de estas comprobaciones. No se publican porcentajes de cobertura, tiempos de despliegue ni resultados de producción sin evidencia reproducible.

Qué queda fuera de esta versión

Límites de la implementación revisada

  • Sin autenticación ni autorización por usuario en las rutas revisadas.
  • El estado success no comprueba que el subdominio responda por HTTP.
  • Sin garantía de continuidad entre historial y suscripción a logs.
  • Sin rollback automático de todos los recursos al fallar el workflow.
  • El SHA se guarda como metadato, pero CloneRepository no hace checkout explícito de ese commit.

Planeado en el README; no implementado

  • Auto-deploy por webhook de GitHub
  • Variables de entorno por aplicación
  • Logs del contenedor después del despliegue
  • Métricas de CPU/RAM en el dashboard
  • Autenticación del dashboard
  • CLI de despliegue

Revisar la implementación