# lbk8s — LoadBalancer L2 estilo MetalLB (Go)

Implementación mínima desde cero de un balanceador `type=LoadBalancer` para clústeres sin cloud provider. Modo **L2 (ARP)**, solo IPv4.

## 1. Cómo funciona

Dos componentes, ambos en el mismo binario/imagen:

| Componente | Despliegue | Qué hace |
|---|---|---|
| `cmd/controller` | Deployment, 1 réplica | Asigna IPs de los pools a los Services `type=LoadBalancer` |
| `cmd/speaker` | DaemonSet, `hostNetwork` | Anuncia por ARP las IPs de las que su nodo es "líder" |

### Controller
1. Lee los pools de `/etc/lbk8s/config.yaml` (montado desde el ConfigMap `lbk8s-config`; se relee cada 30 s y en cada evento).
2. Observa todos los Services (informer). Para cada uno con `type=LoadBalancer` (y `loadBalancerClass` vacío o `lbk8s.io/l2`):
   - Si ya tiene IP válida de un pool, la conserva.
   - Si no, toma la primera IP libre del pool y la escribe en `status.loadBalancer.ingress` (subresource `services/status`).
3. Si un Service deja de ser LoadBalancer, borra su IP del status y la libera.

Anotaciones soportadas en el Service:
- `lbk8s.io/address-pool: <pool>` — elegir pool.
- `lbk8s.io/loadBalancerIPs: <ip>` — pedir una IP concreta (también `spec.loadBalancerIP`).

Formato del pool (`addresses` admite rangos `a-b` o CIDR; `autoAssign: false` exige pedirlo por anotación):
```yaml
pools:
- name: default
  addresses: ["10.5.0.200-10.5.0.220"]
```

### Speaker
Corre en **cada nodo** con `hostNetwork: true` y la capability `NET_RAW`. Observa Services, EndpointSlices y Nodes y, para cada IP asignada:
1. Calcula los nodos candidatos: los `Ready` y, si `externalTrafficPolicy: Local`, solo los que tienen un endpoint listo del Service.
2. Elige un **líder determinista** (hash SHA-256 de `ip/nodo`, gana el menor). Todos los speakers llegan al mismo resultado sin coordinarse.
3. Si el líder es este nodo: responde a los **ARP request** de esa IP con la MAC de su interfaz y envía **ARP gratuitos** (al ganar el liderazgo y cada 20 s).
4. Si el líder cae (nodo NotReady), el resto recalcula y otro nodo toma la IP.

El tráfico llega al nodo líder con destino la IP virtual y **kube-proxy** (iptables/ipvs) lo reenvía a los pods. El speaker no toca el dataplane, solo contesta ARP.

```
 cliente ──ARP "¿quién tiene 10.5.0.200?"──► nodo líder (speaker responde con su MAC)
 cliente ──TCP 10.5.0.200:80──► nodo líder ──kube-proxy──► pod whoami (en cualquier nodo)
```

### Limitaciones
- Solo L2/IPv4; sin BGP ni IPv6.
- Controller sin leader election (1 réplica, estrategia `Recreate`).
- No se puede compartir una IP entre varios Services.
- Todo el tráfico de una IP pasa por un único nodo (ancho de banda de ese nodo).
- Los clientes deben estar en el mismo dominio L2 que los nodos.

## 2. Estructura

```
cmd/controller/   asignación de IPs
cmd/speaker/      anuncio ARP + elección de líder
internal/pool/    parseo de pools y asignación (con test)
deploy/lbk8s.yaml namespace, ConfigMap, RBAC, Deployment, DaemonSet
deploy/example.yaml  whoami + Service LoadBalancer de prueba
Dockerfile        imagen con /controller y /speaker
```

## 3. Instalación en Talos sobre Docker

Entorno: clúster Talos v1.14.5 creado con Docker (3 nodos, contexto `admin@lb-k8s`), red Docker `lb-k8s` = `10.5.0.0/24`:

| Nodo | IP |
|---|---|
| lb-k8s-controlplane-1 | 10.5.0.2 |
| lb-k8s-worker-1 | 10.5.0.3 |
| lb-k8s-worker-2 | 10.5.0.4 |

Los nodos son contenedores, así que **la red Docker es el dominio L2**. Por eso el pool es `10.5.0.200-10.5.0.220` (libre dentro de esa subred).

### 3.1 Registry local (Talos no puede hacer `docker load`)
Talos usa containerd propio y no ve las imágenes del Docker del host, así que se publica la imagen en un registry dentro de la misma red:

```bash
# registry en la red lb-k8s con IP fija, y expuesto en localhost:5001 para hacer push
docker run -d --name lbk8s-registry --network lb-k8s --ip 10.5.0.10 \
  -p 127.0.0.1:5001:5000 registry:2

# construir y subir (arquitectura nativa: arm64 en Apple Silicon)
docker build -t localhost:5001/lbk8s:dev .
docker push localhost:5001/lbk8s:dev
```

### 3.2 Decirle a Talos que use ese registry por HTTP
El registry no tiene TLS, hay que añadir un mirror en la config de máquina de los nodos (se aplica **sin reiniciar**):

```bash
cat > reg.yaml <<'EOF'
machine:
  registries:
    mirrors:
      10.5.0.10:5000:
        endpoints:
          - http://10.5.0.10:5000
EOF
talosctl -n 10.5.0.2,10.5.0.3,10.5.0.4 patch mc --patch @reg.yaml --mode no-reboot

# comprobar que un nodo descarga la imagen
talosctl -n 10.5.0.3 image pull 10.5.0.10:5000/lbk8s:dev
```

### 3.3 PodSecurity
Talos aplica PodSecurity `baseline` por defecto. El speaker necesita `hostNetwork` y `NET_RAW`, así que el namespace lleva la etiqueta `pod-security.kubernetes.io/enforce: privileged` (ya incluida en `deploy/lbk8s.yaml`). Al hacer `kubectl apply` verás un aviso sobre `restricted`; es solo informativo.

### 3.4 Desplegar
```bash
kubectl apply -f deploy/lbk8s.yaml     # imagen 10.5.0.10:5000/lbk8s:dev, pool 10.5.0.200-220
kubectl apply -f deploy/example.yaml   # whoami x2 + Service LoadBalancer
```

## 4. Cómo ver el Service y comprobar que funciona

```bash
# Estado de los componentes
kubectl -n lbk8s-system get pods -o wide        # 1 controller + 1 speaker por nodo

# El Service con su EXTERNAL-IP asignada
kubectl get svc whoami
#  NAME     TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)
#  whoami   LoadBalancer   10.107.176.247   10.5.0.200    80:32210/TCP

# Detalle (events, status.loadBalancer.ingress)
kubectl describe svc whoami
kubectl get svc whoami -o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}'

# Logs: qué IP asignó el controller y qué nodo la anuncia
kubectl -n lbk8s-system logs deploy/controller
kubectl -n lbk8s-system logs -l app=lbk8s-speaker --prefix
#  [speaker-d67vz] anunciando 10.5.0.200      <- ese es el nodo líder
```

### Probar el acceso
En Docker Desktop (macOS) la red `10.5.0.0/24` vive dentro de la VM de Docker y **no es alcanzable desde el Mac**. Prueba desde un contenedor en la misma red L2:

```bash
docker run --rm --network lb-k8s alpine sh -c \
  'apk add -q curl iproute2; curl -s http://10.5.0.200; ip neigh show 10.5.0.200'
```

Resultado esperado: la respuesta de `whoami` y una entrada ARP `10.5.0.200 lladdr <MAC del nodo líder> REACHABLE`. Puedes confirmar la MAC con los logs del speaker (`speaker en nodo X, interfaz eth0 (MAC)`).

Para acceder desde el Mac puedes usar `kubectl port-forward svc/whoami 8080:80`, aunque eso no pasa por el balanceador.

### Probar failover
```bash
docker stop lb-k8s-worker-2        # el nodo que anunciaba la IP
kubectl get nodes                  # tarda unos segundos en pasar a NotReady
kubectl -n lbk8s-system logs -l app=lbk8s-speaker --prefix --tail=3   # otro nodo: "anunciando 10.5.0.200"
docker start lb-k8s-worker-2
```

### Pedir una IP concreta o un pool
```yaml
metadata:
  annotations:
    lbk8s.io/loadBalancerIPs: 10.5.0.210
```

## 5. Desarrollo
```bash
go test ./...
GOOS=linux go vet ./...
```
Tras cambiar código: reconstruir, `docker push localhost:5001/lbk8s:dev` y `kubectl -n lbk8s-system rollout restart deploy/controller ds/speaker`. (Si el tag `dev` está cacheado en los nodos, usa un tag nuevo, p. ej. `:v2`, y actualiza `deploy/lbk8s.yaml`.)

## 6. Desinstalar
```bash
kubectl delete -f deploy/example.yaml -f deploy/lbk8s.yaml
docker rm -f lbk8s-registry
```
