De Cero a Kubernetes Parte 2: Bootstrap del Cluster — Namespaces, Ingress, cert-manager y Storage

K8s Terraform ArgoCD — Header

K8s Terraform ArgoCD — Header

Serie: De Cero a Kubernetes — Parte 1 · Parte 2 · Parte 3 · Parte 4 · Parte 5

En la Parte 1 construimos un cluster Kubernetes con Terraform y kubeadm. Tres nodos listos pero vacíos. Hoy lo convertimos en una plataforma utilizable: tráfico externo con TLS automático, storage para bases de datos, namespaces que separan entornos, y permisos que evitan que un deploy en staging borre producción.

Todo con Terraform. Cero kubectl apply manual.

¿Por qué Terraform para el bootstrap y no ArgoCD?

Esta es una pregunta que recibimos mucho. La respuesta está en el orden de dependencias:

┌─────────────────────────────────────────┐
│  Terraform (infraestructura + bootstrap) │
│  VMs, redes, kubeadm, namespaces,       │
│  ingress, cert-manager, storage, RBAC   │
│  ↓                                      │
│  Terraform instala ArgoCD               │
│  ↓                                      │
│  ArgoCD maneja las aplicaciones         │
│  (backend, frontends, DB)               │
└─────────────────────────────────────────┘

Terraform gestiona lo que ArgoCD necesita para existir. Una vez que ArgoCD está corriendo, le pasamos el control de las aplicaciones. Si intentas usar ArgoCD para bootstrappear el ingress controller que necesita ArgoCD para exponer su propia UI, tienes un problema circular. Terraform rompe ese ciclo.

Lo que construiremos hoy

┌──────────────────────────────────────────────┐
│                 INTERNET                      │
│                    │                          │
│         ┌──────────▼──────────┐               │
│         │  nginx ingress      │               │
│         │  (NodePort 30080)   │               │
│         │  + cert-manager     │               │
│         │  + Let's Encrypt    │               │
│         └──────────┬──────────┘               │
│                    │                          │
│    ┌───────────────┼───────────────┐          │
│    ▼               ▼               ▼          │
│ ┌────────┐  ┌──────────┐  ┌──────────┐       │
│ │production│ │ staging  │  │infrastructure│    │
│ │namespace │ │namespace │  │ namespace  │    │
│ └────────┘  └──────────┘  └──────────┘       │
│                                                │
│ Storage: local-path-provisioner (PVC)          │
│ RBAC: ServiceAccounts + Roles                 │
└──────────────────────────────────────────────┘

Paso 1: Conectar Terraform al cluster

Necesitamos el provider de Kubernetes apuntando al cluster que creamos en la Parte 1:

# providers.tf (agregar al main.tf de la Parte 1)
provider "kubernetes" {
  host                   = "https://${hcloud_server.control_plane.ipv4_address}:6443"
  client_certificate     = base64decode(data.local_file.kubeconfig.content)
  client_key             = base64decode(data.local_file.kubeconfig.content)
  cluster_ca_certificate = base64decode(data.local_file.kubeconfig.content)
  # ⚠️ En producción: usar data source apropiado para extraer
  # cada campo del kubeconfig. Aquí simplificamos con un
  # provisioner que copia el archivo del control-plane.
}

provider "helm" {
  kubernetes {
    host                   = "https://${hcloud_server.control_plane.ipv4_address}:6443"
    client_certificate     = base64decode(var.k8s_client_cert)
    client_key             = base64decode(var.k8s_client_key)
    cluster_ca_certificate = base64decode(var.k8s_ca_cert)
  }
}

Nota práctica: Para este tutorial, extraemos los certificados del kubeconfig con un local_file data source. En producción, usarías un backend de secretos (Vault, AWS Secrets Manager) y los inyectarías como variables de entorno.

Paso 2: Namespaces — la base de todo

Los namespaces son la unidad de aislamiento lógico en Kubernetes. Sin ellos, todos los recursos viven en default y el desorden es garantía de problemas:

resource "kubernetes_namespace" "production" {
  metadata {
    name = "production"
    labels = {
      environment = "production"
      managed-by  = "terraform"
    }
  }
}

resource "kubernetes_namespace" "staging" {
  metadata {
    name = "staging"
    labels = {
      environment = "staging"
      managed-by  = "terraform"
    }
  }
}

resource "kubernetes_namespace" "infrastructure" {
  metadata {
    name = "infrastructure"
    labels = {
      environment = "infrastructure"
      managed-by  = "terraform"
    }
  }
}

resource "kubernetes_namespace" "argocd" {
  metadata {
    name = "argocd"
    labels = {
      environment = "infrastructure"
      managed-by  = "terraform"
    }
  }
}

La convención: production para cargas de trabajo reales, staging para pruebas pre-producción, infrastructure para componentes del sistema (ingress, cert-manager, storage), argocd para la herramienta GitOps.

Paso 3: Storage — local-path-provisioner

Kubernetes necesita un StorageClass que cree PersistentVolumes dinámicamente cuando un pod pide un PVC. En Hetzner usamos local-path-provisioner (de Rancher), que crea volúmenes en el disco local del nodo:

resource "helm_release" "local_path_provisioner" {
  name       = "local-path-provisioner"
  namespace  = kubernetes_namespace.infrastructure.metadata[0].name
  repository = "https://rancher.github.io/local-path-provisioner"
  chart      = "local-path-provisioner"
  version    = "0.0.28"

  set {
    name  = "storageClass.defaultClass"
    value = "true"
  }

  set {
    name  = "nodePathMap[0].node"
    value = "DEFAULT_PATH_FOR_NON_LISTED_NODES"
  }

  set {
    name  = "nodePathMap[0].paths[0]"
    value = "/var/local-path-provisioner"
  }
}

¿Por qué no Rook/Ceph? Para un cluster de 3 nodos modestos, Ceph es sobreingeniería. local-path-provisioner es perfecto: los datos viven en /var/local-path-provisioner del nodo donde corre el pod. La limitación es que si ese nodo muere, el volumen se pierde. Para producción seria, más adelante migras a Longhorn (replicación) o usas volúmenes de cloud (Hetzer volumes).

Paso 4: nginx ingress controller — la puerta de entrada

El ingress controller es el reverse proxy que recibe tráfico externo y lo rutea a los Services correctos dentro del cluster. Usamos nginx, el más probado del ecosistema:

resource "helm_release" "ingress_nginx" {
  name       = "ingress-nginx"
  namespace  = kubernetes_namespace.infrastructure.metadata[0].name
  repository = "https://kubernetes.github.io/ingress-nginx"
  chart      = "ingress-nginx"
  version    = "4.11.2"

  set {
    name  = "controller.service.type"
    value = "NodePort"
  }

  set {
    name  = "controller.service.nodePorts.http"
    value = "30080"
  }

  set {
    name  = "controller.service.nodePorts.https"
    value = "30443"
  }

  set {
    name  = "controller.config.proxy-body-size"
    value = "50m"
  }

  set {
    name  = "controller.config.proxy-read-timeout"
    value = "60"
  }

  set {
    name  = "controller.config.proxy-send-timeout"
    value = "60"
  }
}

Detalles importantes:

  • Usamos NodePort porque Hetzner no tiene LoadBalancer nativo. El ingress escucha en el puerto 30080 (HTTP) y 30443 (HTTPS) de cada nodo
  • proxy-body-size: 50m permite uploads de archivos
  • Los timeouts de proxy en 60s evitan que requests largos (migraciones, backups) se corten

En producción real: apuntas un balanceador de carga (Hetzner Load Balancer, Cloudflare Tunnel, o un nginx externo) al NodePort 30080/30443 de los workers. También puedes instalar hcloud-cloud-controller-manager para que Hetzner cree Load Balancers automáticamente.

Paso 5: cert-manager — TLS automático con Let’s Encrypt

Nadie debería manejar certificados TLS a mano en 2026. cert-manager los crea, renueva 30 días antes de expirar, y los inyecta como Secrets:

resource "helm_release" "cert_manager" {
  name       = "cert-manager"
  namespace  = kubernetes_namespace.infrastructure.metadata[0].name
  repository = "https://charts.jetstack.io"
  chart      = "cert-manager"
  version    = "1.16.1"

  set {
    name  = "installCRDs"
    value = "true"
  }

  set {
    name  = "prometheus.enabled"
    value = "false"
  }
}

Después de instalar cert-manager, creamos los ClusterIssuers:

# Staging — para pruebas, sin rate limits fuertes
resource "kubernetes_manifest" "cluster_issuer_staging" {
  manifest = {
    apiVersion = "cert-manager.io/v1"
    kind       = "ClusterIssuer"
    metadata = {
      name = "letsencrypt-staging"
    }
    spec = {
      acme = {
        server  = "https://acme-staging-v02.api.letsencrypt.org/directory"
        email   = var.letsencrypt_email
        privateKeySecretRef = {
          name = "letsencrypt-staging-account-key"
        }
        solvers = [{
          http01 = {
            ingress = {
              class = "nginx"
            }
          }
        }]
      }
    }
  }
}

# Production — certificados de verdad
resource "kubernetes_manifest" "cluster_issuer_production" {
  manifest = {
    apiVersion = "cert-manager.io/v1"
    kind       = "ClusterIssuer"
    metadata = {
      name = "letsencrypt-production"
    }
    spec = {
      acme = {
        server  = "https://acme-v02.api.letsencrypt.org/directory"
        email   = var.letsencrypt_email
        privateKeySecretRef = {
          name = "letsencrypt-production-account-key"
        }
        solvers = [{
          http01 = {
            ingress = {
              class = "nginx"
            }
          }
        }]
      }
    }
  }

  depends_on = [helm_release.cert_manager]
}

La diferencia staging vs production: Let’s Encrypt tiene rate limits estrictos en producción (50 certificados por dominio por semana). Si estás probando configuraciones de ingress, usas staging. Cuando todo funciona, cambias a production.

Paso 6: RBAC mínimo viable

Para este tutorial necesitamos un ServiceAccount que las aplicaciones usarán:

resource "kubernetes_service_account" "apps" {
  metadata {
    name      = "apps-sa"
    namespace = kubernetes_namespace.production.metadata[0].name
  }
}

resource "kubernetes_role" "apps" {
  metadata {
    name      = "apps-role"
    namespace = kubernetes_namespace.production.metadata[0].name
  }

  rule {
    api_groups = [""]
    resources  = ["pods", "services", "configmaps", "secrets"]
    verbs      = ["get", "list", "watch"]
  }

  rule {
    api_groups = ["apps"]
    resources  = ["deployments"]
    verbs      = ["get", "list", "watch"]
  }
}

resource "kubernetes_role_binding" "apps" {
  metadata {
    name      = "apps-rolebinding"
    namespace = kubernetes_namespace.production.metadata[0].name
  }

  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "Role"
    name      = kubernetes_role.apps.metadata[0].name
  }

  subject {
    kind      = "ServiceAccount"
    name      = kubernetes_service_account.apps.metadata[0].name
    namespace = kubernetes_namespace.production.metadata[0].name
  }
}

Esto es intencionalmente restrictivo: las apps pueden leer recursos pero no modificarlos ni crear nuevos. En la Parte 4 configuraremos un ServiceAccount específico para el backend con permisos de lectura de Secrets.

Paso 7: Verificación — probar TLS real

Creamos un ingress de prueba con TLS:

resource "kubernetes_manifest" "test_ingress" {
  manifest = {
    apiVersion = "networking.k8s.io/v1"
    kind       = "Ingress"
    metadata = {
      name      = "test-ingress"
      namespace = "production"
      annotations = {
        "cert-manager.io/cluster-issuer"            = "letsencrypt-staging"
        "nginx.ingress.kubernetes.io/ssl-redirect"  = "true"
      }
    }
    spec = {
      ingressClassName = "nginx"
      tls = [{
        hosts      = ["test.tudominio.com"]
        secretName = "test-tls"
      }]
      rules = [{
        host = "test.tudominio.com"
        http = {
          paths = [{
            path     = "/"
            pathType = "Prefix"
            backend = {
              service = {
                name = "echo-service"
                port = { number = 80 }
              }
            }
          }]
        }
      }]
    }
  }
}

Después de terraform apply, apuntas el DNS test.tudominio.com a la IP pública de cualquier nodo y verificas:

curl -v https://test.tudominio.com:30443
# < HTTP/2 200
# < SSL certificate verified: CN = test.tudominio.com
# (Fake LE Intermediate X1) ← staging, esperado

Cuando confirmas que funciona, cambias el annotation a letsencrypt-production y obtienes un certificado real. cert-manager lo renovará automáticamente cada 60 días.

Estructura final de Terraform hasta ahora

infra/
├── main.tf                    # Providers + recursos principales
├── variables.tf
├── outputs.tf
├── terraform.tfvars
├── versions.tf
├── cloud-init/
│   ├── control-plane.yaml
│   └── worker.yaml
├── modules/
│   ├── kubernetes-core/       # (opcional — refactor futuro)
│   │   ├── namespaces.tf
│   │   ├── storage.tf
│   │   ├── ingress.tf
│   │   ├── cert-manager.tf
│   │   └── rbac.tf
└── .gitignore

Qué aprendiste en esta parte

  • Namespaces como capa de aislamiento entre entornos
  • Por qué Terraform maneja el bootstrap y ArgoCD maneja las apps
  • nginx ingress controller expuesto vía NodePort
  • cert-manager + Let’s Encrypt para TLS automático con renovación
  • local-path-provisioner como StorageClass dinámica
  • RBAC mínimo viable con ServiceAccounts, Roles y RoleBindings
  • El flujo ClusterIssuer → Ingress annotation → Secret TLS → certificado listo

En la Parte 3, desplegaremos ArgoCD, configuraremos GitOps con el patrón app-of-apps, y tendremos nuestro primer despliegue automático desde GitHub.

En Guayoyo Tech no solo escribimos tutoriales — diseñamos y desplegamos arquitecturas como esta para empresas reales. Kubernetes, Terraform, ArgoCD, pipelines CI/CD, y todo el ecosistema cloud-native: lo armamos contigo, sin humo y sin vendor lock-in. Si quieres que tu infraestructura se parezca a lo que acabas de leer, hablemos gratis 15 minutos. Tu cluster, tu nube, tus reglas — nosotros ponemos la ingeniería.

De Cero a Kubernetes Parte 1: Infraestructura con Terraform y Bootstrap con kubeadm

K8s Terraform ArgoCD — Header

K8s Terraform ArgoCD — Header

Serie: De Cero a Kubernetes — Parte 1 · Parte 2 · Parte 3 · Parte 4 · Parte 5

Esta es la primera parte de una guía de 5 entregas donde vamos a construir, desde cero absoluto, un cluster Kubernetes completo con Terraform, desplegar un backend con base de datos, dos frontends independientes, y gestionarlo todo con GitOps usando ArgoCD.

No es un tutorial de “haz clic aquí y cruza los dedos”. Vamos a entender cada pieza. Al final de la serie tendrás un cluster real corriendo en la nube, con TLS automático, deployments reproducibles, y todo el código versionado en GitHub.

Lo que construiremos en esta Parte 1:

  • 3 VMs (1 control-plane + 2 workers) en Hetzner Cloud con Terraform
  • Red privada entre VMs, firewalls con puertos Kubernetes, SSH keys
  • containerd como container runtime en cada nodo
  • kubeadm init en el control-plane, kubeadm join en los workers
  • Calico como CNI (Container Network Interface)
  • Todo automatizado vía cloud-init — en 5 minutos tienes cluster

¿Por qué kubeadm y no un servicio managed?

EKS, GKE y AKS son excelentes. Pero cuando usas un managed service, no tocas el control-plane, no ves etcd, no configuras el CNI, no entiendes qué pasa cuando un nodo no se une al cluster.

kubeadm es la herramienta oficial de Kubernetes para bootstrappear clusters. Es lo que usan los ingenieros que construyen plataformas. Al terminar esta serie, sabrás exactamente qué hace cada componente y podrás reproducir este cluster en cualquier proveedor: Hetzner, AWS EC2, GCP Compute Engine, o tus propias máquinas físicas.

Arquitectura de infraestructura

┌──────────────────────────────────────────────────────┐
│                  Hetzner Cloud                        │
│                                                      │
│  ┌─────────────────────┐  ┌──────────┐  ┌──────────┐ │
│  │   control-plane     │  │ worker-0 │  │ worker-1 │ │
│  │   (CX21, 2vCPU/4GB) │  │(CX21)    │  │(CX21)    │ │
│  │                     │  │          │  │          │ │
│  │ api-server          │  │ kubelet  │  │ kubelet  │ │
│  │ etcd                │  │ containerd│ │ containerd│ │
│  │ controller-manager  │  │ Calico   │  │ Calico   │ │
│  │ scheduler           │  │          │  │          │ │
│  │ kubelet             │  │          │  │          │ │
│  │ containerd          │  │          │  │          │ │
│  │ Calico              │  │          │  │          │ │
│  └────────┬────────────┘  └────┬─────┘  └────┬─────┘ │
│           │                    │             │       │
│           └────────────────────┼─────────────┘       │
│                                │                     │
│                    ┌───────────▼───────────┐         │
│                    │   Red privada         │         │
│                    │   (10.0.0.0/16)      │         │
│                    └───────────────────────┘         │
│                                                      │
│   Firewall: SSH(22) + k8s API(6443) + NodePorts      │
│   + Calico networking (179/tcp, 4789/udp)            │
└──────────────────────────────────────────────────────┘

Estructura del proyecto Terraform

Creamos un directorio infra/ en nuestro monorepo:

infra/
├── main.tf              # Providers, recursos principales
├── variables.tf         # Variables de entrada
├── outputs.tf           # Outputs: IPs, kubeconfig
├── terraform.tfvars     # Valores (no commitear secretos)
├── versions.tf          # Versiones de providers
├── cloud-init/
│   ├── control-plane.yaml
│   └── worker.yaml
└── .gitignore

Paso 1: Configurar providers y variables

versions.tf — Bloqueamos versiones para reproducibilidad:

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.47"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}

main.tf — El esqueleto:

provider "hcloud" {
  token = var.hcloud_token
}

# Red privada
resource "hcloud_network" "k8s" {
  name     = "k8s-network"
  ip_range = "10.0.0.0/16"
}

resource "hcloud_network_subnet" "k8s" {
  network_id   = hcloud_network.k8s.id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = "10.0.1.0/24"
}

Paso 2: SSH keys

resource "tls_private_key" "k8s" {
  algorithm = "ED25519"
}

resource "hcloud_ssh_key" "k8s" {
  name       = "k8s-cluster-key"
  public_key = tls_private_key.k8s.public_key_openssh
}

Terraform guarda la clave privada en el state. Puedes extraerla con terraform output -raw ssh_private_key.

Paso 3: Firewall con los puertos exactos de Kubernetes

No abras todo. Kubernetes necesita puertos específicos:

resource "hcloud_firewall" "k8s" {
  name = "k8s-firewall"

  # SSH desde cualquier IP (o tu IP fija para producción)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = ["0.0.0.0/0"]
  }

  # Kubernetes API (control-plane)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "6443"
    source_ips = ["0.0.0.0/0"]
  }

  # NodePort range (30000-32767 para ingress y servicios)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "30000-32767"
    source_ips = ["0.0.0.0/0"]
  }

  # Calico BGP (179/tcp) y VXLAN (4789/udp)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "179"
    source_ips = ["10.0.0.0/16"]
  }
  rule {
    direction  = "in"
    protocol   = "udp"
    port       = "4789"
    source_ips = ["10.0.0.0/16"]
  }

  # Comunicación interna completa entre nodos del cluster
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "10250-10256"
    source_ips = ["10.0.0.0/16"]
  }
}

Explicación de cada puerto:

  • 6443 → API server. kubectl y kubeadm hablan con este endpoint
  • 30000-32767 → Rango NodePort. El ingress nginx se expone por aquí mientras no tengamos LoadBalancer
  • 179/tcp → BGP de Calico para compartir rutas entre nodos
  • 4789/udp → VXLAN de Calico para encapsular tráfico de pods entre nodos
  • 10250 → Kubelet API (logs, exec, métricas)
  • 10256 → kube-proxy health check
  • 10257-10259 → Controller manager y scheduler (solo en control-plane)

Paso 4: Las VMs — el corazón del cluster

# Control-plane
resource "hcloud_server" "control_plane" {
  name        = "k8s-control-plane"
  server_type = "cx21"
  image       = "ubuntu-24.04"
  location    = "nbg1"
  ssh_keys    = [hcloud_ssh_key.k8s.id]
  firewall_ids = [hcloud_firewall.k8s.id]

  network {
    network_id = hcloud_network.k8s.id
    ip         = "10.0.1.10"
  }

  # cloud-init ejecuta todo el bootstrap al primer boot
  user_data = templatefile("${path.module}/cloud-init/control-plane.yaml", {
    pod_network_cidr = "192.168.0.0/16"
  })

  depends_on = [hcloud_network_subnet.k8s]
}

# Workers
resource "hcloud_server" "worker" {
  count = 2

  name         = "k8s-worker-${count.index}"
  server_type  = "cx21"
  image        = "ubuntu-24.04"
  location     = "nbg1"
  ssh_keys     = [hcloud_ssh_key.k8s.id]
  firewall_ids = [hcloud_firewall.k8s.id]

  network {
    network_id = hcloud_network.k8s.id
    ip         = "10.0.1.${20 + count.index}"
  }

  user_data = templatefile("${path.module}/cloud-init/worker.yaml", {
    control_plane_ip = "10.0.1.10"
    # El token se genera en el cloud-init del control-plane
    # y se comparte vía output de Terraform
  })

  depends_on = [hcloud_server.control_plane]
}

Paso 5: cloud-init — la magia que automatiza todo

Aquí es donde sucede la instalación real de Kubernetes. cloud-init ejecuta estos scripts en el primer boot de cada VM.

cloud-init/control-plane.yaml:

#cloud-config
hostname: control-plane
package_update: true
package_upgrade: true

packages:
  - containerd
  - apt-transport-https
  - ca-certificates
  - curl
  - gnupg

write_files:
  # Configuración de containerd con systemd como cgroup driver
  - path: /etc/containerd/config.toml
    permissions: '0644'
    content: |
      version = 2
      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
        runtime_type = "io.containerd.runc.v2"
        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
          SystemdCgroup = true

  # Modprobe overlay y br_netfilter para networking de pods
  - path: /etc/modules-load.d/k8s.conf
    content: |
      overlay
      br_netfilter

  # Parámetros de kernel para networking
  - path: /etc/sysctl.d/k8s.conf
    content: |
      net.bridge.bridge-nf-call-iptables  = 1
      net.bridge.bridge-nf-call-ip6tables = 1
      net.ipv4.ip_forward                 = 1

runcmd:
  # 1. Cargar módulos de kernel y sysctl
  - modprobe overlay
  - modprobe br_netfilter
  - sysctl --system

  # 2. Desactivar swap (Kubernetes lo requiere)
  - swapoff -a
  - sed -i '/swap/d' /etc/fstab

  # 3. Reiniciar containerd con la nueva config
  - systemctl restart containerd
  - systemctl enable containerd

  # 4. Agregar repo de Kubernetes y instalar kubeadm, kubelet, kubectl
  - |
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
    apt-get update
    apt-get install -y kubelet kubeadm kubectl
    apt-mark hold kubelet kubeadm kubectl

  # 5. Inicializar el cluster
  - |
    kubeadm init 
      --pod-network-cidr=${pod_network_cidr} 
      --apiserver-advertise-address=10.0.1.10 
      --upload-certs 
      | tee /root/kubeadm-init.log

  # 6. Configurar kubeconfig para root
  - mkdir -p /root/.kube
  - cp /etc/kubernetes/admin.conf /root/.kube/config

  # 7. Instalar Calico
  - |
    kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/tigera-operator.yaml --kubeconfig=/etc/kubernetes/admin.conf
    kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/custom-resources.yaml --kubeconfig=/etc/kubernetes/admin.conf

  # 8. Generar token de join para workers y guardarlo
  - kubeadm token create --print-join-command > /root/join-command.sh
  - chmod 644 /root/join-command.sh

final_message: "Control-plane bootstrap completado en $UPTIME segundos"

cloud-init/worker.yaml:

#cloud-config
hostname: worker
package_update: true
package_upgrade: true

packages:
  - containerd
  - apt-transport-https
  - ca-certificates
  - curl
  - gnupg

write_files:
  - path: /etc/containerd/config.toml
    permissions: '0644'
    content: |
      version = 2
      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
        runtime_type = "io.containerd.runc.v2"
        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
          SystemdCgroup = true

  - path: /etc/modules-load.d/k8s.conf
    content: |
      overlay
      br_netfilter

  - path: /etc/sysctl.d/k8s.conf
    content: |
      net.bridge.bridge-nf-call-iptables  = 1
      net.bridge.bridge-nf-call-ip6tables = 1
      net.ipv4.ip_forward                 = 1

runcmd:
  - modprobe overlay
  - modprobe br_netfilter
  - sysctl --system
  - swapoff -a
  - sed -i '/swap/d' /etc/fstab
  - systemctl restart containerd
  - systemctl enable containerd

  # Instalar kubeadm, kubelet, kubectl
  - |
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
    apt-get update
    apt-get install -y kubelet kubeadm kubectl
    apt-mark hold kubelet kubeadm kubectl

  # Esperar que el join-command esté disponible
  # (esto se resuelve con el script de abajo)

final_message: "Worker bootstrap completado en $UPTIME segundos. Ejecuta join manual luego del init del control-plane."

Paso 6: Unir los workers al cluster

Como el join-command se genera en el control-plane durante el bootstrap, necesitamos que los workers lo obtengan después. Usamos un recurso null_resource en Terraform o lo hacemos manual la primera vez:

# Desde tu máquina local, después del terraform apply:
scp root@<control-plane-ip>:/root/join-command.sh .
scp join-command.sh root@<worker-0-ip>:/root/
ssh root@<worker-0-ip> "bash /root/join-command.sh"
# Repetir para worker-1

En producción esto se automatiza con Vault o un bucket S3 compartido. Para el tutorial, este paso manual de 2 minutos es suficiente y te deja ver exactamente qué pasa.

Paso 7: Verificación

$ terraform apply
# ... (2-3 minutos para crear VMs + cloud-init)

$ ssh root@<control-plane-ip>

root@control-plane:~# kubectl get nodes
NAME               STATUS   ROLES           AGE   VERSION
k8s-control-plane  Ready    control-plane   5m    v1.31.0
k8s-worker-0       Ready    <none>          2m    v1.31.0
k8s-worker-1       Ready    <none>          2m    v1.31.0

root@control-plane:~# kubectl get pods -A
NAMESPACE          NAME                                      READY   STATUS    RESTARTS   AGE
calico-system      calico-kube-controllers-xxxxx             1/1     Running   0          4m
calico-system      calico-node-xxxxx                         1/1     Running   0          4m
calico-system      calico-node-xxxxx                         1/1     Running   0          2m
calico-system      calico-node-xxxxx                         1/1     Running   0          2m
kube-system        coredns-xxxxx                             1/1     Running   0          5m
kube-system        coredns-xxxxx                             1/1     Running   0          5m
kube-system        etcd-control-plane                        1/1     Running   0          5m
kube-system        kube-apiserver-control-plane              1/1     Running   0          5m
kube-system        kube-controller-manager-control-plane     1/1     Running   0          5m
kube-system        kube-proxy-xxxxx                          1/1     Running   0          5m
kube-system        kube-scheduler-control-plane              1/1     Running   0          5m

Tres nodos Ready, todos los pods del sistema corriendo. Cluster listo.

Outputs de Terraform

output "control_plane_ip" {
  value = hcloud_server.control_plane.ipv4_address
}

output "worker_ips" {
  value = [for w in hcloud_server.worker : w.ipv4_address]
}

output "ssh_private_key" {
  value     = tls_private_key.k8s.private_key_pem
  sensitive = true
}

output "kubeconfig" {
  value     = "${path.module}/kubeconfig.yaml"  # lo copiamos con un provisioner
  sensitive = true
}

Costos reales

Recurso Precio mensual
3× CX21 (2vCPU, 4GB RAM, 40GB SSD) €11.97
Red privada €0.00
Firewall €0.00
Tráfico (20TB incluido) €0.00
Total ≈ €12/mes

Menos de $13 USD al mes por un cluster Kubernetes real con control-plane dedicado. Compáralo con EKS que cobra $0.10/hora solo por el control-plane ($72/mes) más las VMs aparte.

Qué aprendiste en esta parte

  • Estructura de un proyecto Terraform profesional para Kubernetes
  • Puertos exactos que Kubernetes necesita (y por qué cada uno)
  • cloud-init como herramienta de automatización de bootstrap
  • containerd como container runtime (no Docker — Kubernetes deprecó Docker en v1.24)
  • kubeadm init y los flags críticos (pod-network-cidr, advertise-address)
  • Calico como CNI para networking de pods entre nodos
  • El concepto de control-plane + workers y qué corre en cada uno

En la Parte 2, tomaremos este cluster vacío y lo convertiremos en una plataforma lista para producción: namespaces, ingress controller con TLS automático, storage persistente, y RBAC. Todo con Terraform, sin un solo kubectl apply manual.

En Guayoyo Tech diseñamos y desplegamos arquitecturas Kubernetes como esta para empresas reales. Terraform, kubeadm, GitOps, CI/CD — lo armamos contigo, sin humo y sin vendor lock-in. Si quieres que tu infraestructura se parezca a lo que acabas de leer, hablemos gratis 15 minutos. Tu cluster, tu nube, tus reglas — nosotros ponemos la ingeniería.

Реверс-инжиниринг стриминга FC2: Как построить высокопроизводительный загрузчик на базе HLS и FFmpeg.wasm

Для обычного пользователя «скачать видео» — это тривиальная задача. Однако для инженеров, работающих с такими платформами, как FC2 Video, это превращается в квест по деконструкции протоколов Adaptive Bitrate Streaming (ABS) и манипуляции бинарными данными непосредственно в рантайме браузера.
При разработке FC2 Video Downloader мы решили отказаться от традиционной серверной обработки в пользу клиентских технологий. В этой статье мы разберем архитектуру доставки контента FC2 и покажем, как использовать WebAssembly для сборки видео без потери качества.

1. Анатомия доставки видео на FC2

FC2 не отдает видео в виде монолитных MP4-файлов. Платформа использует протокол HLS (HTTP Live Streaming).
1.1 Структура манифестов M3U8
Процесс воспроизведения начинается с запроса мастер-плейлиста, который имеет иерархическую структуру:
• Master Playlist: содержит ссылки на варианты потоков с разным разрешением (1080p, 720p и т.д.).
• Media Playlist: содержит список сегментов TS (Transport Stream), каждый из которых длится от 2 до 10 секунд.
• Ключи шифрования: если контент защищен, в плейлисте присутствует тег EXT-X-KEY с URL-адресом ключа AES-128.
1.2 Динамическая аутентификация
Серверы FC2 проверяют каждый запрос на сегмент. Для успешного получения чанка необходимо передать валидные токены сессии, которые генерируются обфусцированным JavaScript-кодом на стороне клиента. Любой запрос без правильного Referer и актуальных куки-файлов возвращает 403 Forbidden.

2. Архитектура решения: Почему WebAssembly?

Традиционные загрузчики работают по схеме «прокси на сервере»: сервер скачивает чанки, склеивает их через FFmpeg и отдает готовый файл пользователю. Это дорого (двойной трафик) и плохо с точки зрения приватности.
Мы выбрали путь Edge Processing с использованием FFmpeg.wasm.
2.1 Магия FFmpeg в браузере
Благодаря компиляции FFmpeg (написанного на C) в WebAssembly, мы можем выполнять тяжелые операции над медиафайлами прямо в песочнице браузера.
• Lossless Remuxing: Мы не перекодируем видео (это убило бы CPU пользователя). Мы используем флаг -c copy, который просто меняет контейнер с TS на MP4.
• Privacy-by-Design: Все данные объединяются в оперативной памяти (RAM) пользователя. Видео никогда не касается наших серверов.

3. Инженерные вызовы и их решения

3.1 Обход ограничений CORS
Политика Same-Origin Policy (SOP) запрещает браузеру запрашивать бинарные данные с доменов FC2 напрямую. Чтобы решить эту проблему, мы построили Transparent Streaming Proxy на Node.js:

  1. Клиент запрашивает сегмент через наш прокси.
  2. Прокси забирает данные из CDN FC2.
  3. Прокси «на лету» инжектирует заголовок Access-Control-Allow-Origin: *.
  4. Stream Piping: Данные передаются через прокси без сохранения на диск (Zero-disk usage), что гарантирует минимальную задержку.
    3.2 Управление конкурентностью (Async Pool)
    Видео в 1080p может состоять из 500+ сегментов. Если запрашивать их все одновременно, CDN заблокирует IP. Мы реализовали пул промисов для контроля конкурентности:
    JavaScript
    // Логика управления очередью загрузки
    async function downloadPool(urls, limit) {
    const activeTasks = new Set();
    for (const url of urls) {
    if (activeTasks.size >= limit) {
    await Promise.race(activeTasks); // Ждем освобождения места в пуле
    }
    const task = fetchSegment(url).then(() => activeTasks.delete(task));
    activeTasks.add(task);
    }
    }

4. Оптимизация целостности данных: Коррекция PTS

Специфика FC2 такова, что временные метки PTS (Presentation Time Stamp) в сегментах могут идти не по порядку. Если просто склеить чанки, видео будет «заикаться» или произойдет рассинхрон звука.
Наш движок выполняет Timestamp Alignment: перед финальной сборкой в WASM мы сканируем заголовки TS-пакетов и корректируем смещения, обеспечивая плавное воспроизведение итогового MP4-файла.

5. Заключение: Инструмент от разработчиков для разработчиков

FC2 Video Downloader — это не просто скрипт для скачивания. Это демонстрация того, как современные веб-стандарты (WASM, WebWorkers, Streams API) позволяют перенести сложную серверную логику на сторону клиента.
Основные преимущества нашего подхода:
• Оригинальное качество: Экстракция 1:1 без пересжатия.
• Безопасность: Отсутствие серверного логгирования контента.
• Скорость: Параллельная загрузка и мгновенное мультиплексирование.
Если вам интересна обработка бинарных данных в браузере или архитектура HLS, будем рады обсудить технические детали в комментариях!

Теги: #JavaScript #WebAssembly #FC2 #VideoStreaming #HLS #WebDev #RussianTech

Optimizing Cement Kiln Heat Consumption: A Process Engineer’s Python Approach

For over three decades, my world revolved around the deafening roar of industrial fans, the intense glow of the rotary kiln, and the constant pursuit of the perfect clinker. In those traditional days of cement plant operations, conducting a thermal balance or calculating specific heat consumption meant drowning in manual log sheets, tattered reference manuals, and hours of tedious calculator punching.

Fast forward to today. I am retired from active field duty, but my passion for process engineering hasn’t aged a day. Instead of stepping away, I decided to upgrade my toolkit. At 72, I opened Visual Studio Code, learned Python, and realized something profound: the complex calculations that used to take us half a shift can now be executed, monitored, and optimized in milliseconds.

If you are a junior process engineer still stuck relying solely on rigid, outdated spreadsheets, this is your wake-up call. Let’s look at how we can model kiln heat consumption using clean, modern Python code.

The Engineering Logic: The Thermal Balance
As process engineers, we know that the rotary kiln is a thermal beast. Specific heat consumption—typically measured in kilocalories per kilogram of clinker ($kcal/kgtext{ clinker}$)—is the ultimate metric of a plant’s energy efficiency.

To evaluate this programmatically, our model needs to capture the core variables that dictate thermal behavior:
Kiln Feed Rate (TPH): The raw material entering the system.
Fuel Feed Rate (TPH): The energy source (coal, gas, or alternative fuels).
Calorific Value of Fuel (kcal/kg): The potential energy stored in your fuel mix.
Clinkerization Factor: The chemical conversion ratio determining how much raw feed yields a kilogram of clinker (typically around 1.54 to 1.57 depending on your raw mix composition).

The Python Solution: Bridging the Gap
Instead of a complex, proprietary software interface, we can write a transparent, reusable Python function. This script calculates both the estimated hourly clinker production and the specific heat consumption, giving you an instant snapshot of your kiln’s thermal efficiency.

Here is the code structure you can run right now in your VS Code environment:

Pythondef analyze_kiln_thermal_efficiency(feed_rate, fuel_rate, calorific_value, clinker_factor=1.55):
“””
Calculates Clinker Production and Specific Heat Consumption for a Cement Kiln.

Parameters:
feed_rate (float): Raw kiln feed rate in Tons Per Hour (TPH)
fuel_rate (float): Fuel firing rate in Tons Per Hour (TPH)
calorific_value (float): Lower Heating Value (LHV) of fuel in kcal/kg
clinker_factor (float): Material conversion factor (Default: 1.55)
"""
# 1. Calculate hourly clinker production (Tons/Hour)
clinker_production_tph = feed_rate / clinker_factor

# 2. Total heat input per hour (kcal/hour)
# Converting fuel rate from Tons to kg (multiply by 1000)
total_heat_input_kcal = fuel_rate * 1000 * calorific_value

# 3. Specific Heat Consumption (kcal / kg of clinker)
# Converting clinker production from Tons to kg (multiply by 1000)
clinker_production_kg = clinker_production_tph * 1000
specific_heat_consumption = total_heat_input_kcal / clinker_production_kg

return clinker_production_tph, specific_heat_consumption

— Testing the Model with Real Plant Data —

if name == “main“:
# Example operational values from a typical running kiln
current_feed_rate = 310.0 # TPH
current_fuel_rate = 22.5 # TPH
coal_calorific_value = 6200.0 # kcal/kg

clinker_tph, specific_heat = analyze_kiln_thermal_efficiency(
    feed_rate=current_feed_rate,
    fuel_rate=current_fuel_rate,
    calorific_value=coal_calorific_value
)

print("="*45)
print("        KILN THERMAL ANALYSIS REPORT        ")
print("="*45)
print(f"Estimated Clinker Production : {clinker_tph:.2f} TPH")
print(f"Specific Heat Consumption    : {specific = :.2f} kcal/kg clinker")
print("="*45)

Why This Matters for Plant Data AutomationOnce
you wrap your process logic into Python functions like the one above, you aren’t limited to manual inputs. You can easily connect this script to a live CSV log sheet, a SQL database, or your plant’s DCS data historian to chart heat consumption fluctuations across entire shifts in real-time.

The Vision: Innovation Has No Expiration DateWhen I share these workflows on platforms like DEV.to or LinkedIn, I often get surprised reactions from younger developers and engineers who wonder why a veteran cement operations guy is writing Python code.

My answer is simple:
Engineering is not a title you hold until retirement; it is a way of thinking. The moment we stop adopting new tools to analyze old problems is the moment our industry stagnates. Whether you are optimizing a ball mill, assessing raw mix siloing, or balancing a preheater kiln, the marriage of traditional heavy industry experience with modern data science is where the future lies.

Don’t let the corporate routine box you into clicking the same spreadsheet cells for the next ten years. Open up a code editor, digitize your formulas, and take command of your plant’s data.

What tools are you currently using to track your kiln’s thermal efficiency? Let’s discuss in the comments below!

Join the Industrial Commander Community!
If you found this industrial data approach valuable, don’t miss out on future insights. Subscribe to get deep-dive process engineering strategies, Python automation workflows, and real plant case studies delivered straight to your inbox.

Spread the Knowledge: Know a junior process engineer or a plant manager who needs to see this? Share this post with your network on LinkedIn!

Originally published at https://industrialcommander.substack.com.

How I Turned 6 Cognitive Science Principles Into an AI Agent That Builds Obsidian Vaults

In 30 years, computers got 100x faster. Our learning method stayed the same: read → highlight → re-read → forget → re-read again.

I shared with EMBA students who have to digest hundreds of pages of materials every month — textbooks, case studies, industry reports. They have demanding jobs, families, and a finite number of hours. The ones who fall behind aren’t less capable. They just don’t have a system.

A few months ago I found a way to break that cycle. It combines a century-old insight about how knowledge works with modern AI. I open-sourced the result as VaultForge.

This is the story of what it does and how it’s built.

The Method Was Always There — But It Had Two Bottlenecks

Management consultants face the same problem my audience do. When a consulting firm lands a client in an unfamiliar industry, they have one week to get smart enough to talk to senior executives. They can’t read everything. So they don’t try.

They do three things:

  1. Build a framework first — map out what dimensions matter before reading
  2. Define boundaries — know what they don’t need to know, and actively search for knowledge and information around a few core questions, building connections between knowledge and real-world problems
  3. Find experts — get someone to pinpoint the core consensus, debates and unknowns in 60 minutes, so you aren’t limited by readily available knowledge but stand at the industry frontier to explore its direction and boundaries

Cognitive science backs this up. Passive re-reading creates familiarity, not memory. Active retrieval — explaining, questioning, connecting — is 3-5x more effective.

And then there’s Niklas Luhmann. The German sociologist wrote 70 books and 400+ papers using 90,000 index cards. His secret wasn’t intelligence. It was connection. Every card linked to others. Ideas fermented over years. He said he didn’t think first and then write — he wrote to discover what he was thinking.

That’s the zettelkasten method, formalized as digital tools like Obsidian.

But here’s the problem. The consulting method requires access to experts and years of framework-building practices. Luhmann’s system requires a lifetime of manual card maintenance. Both are inaccessible to normal people with normal schedules.

Two bottlenecks that AI could break.

The Six-Phase Pipeline

VaultForge is an AI agent skill that loads into Claude Code, Codex, or Cursor. Give it a PDF or Markdown file, and it produces a complete Obsidian vault. The engineering is organized into six phases:

Phase 1: Roadmap Generation

The agent reads the full material and produces two outputs: an outline version (H2/H3 hierarchy with bullet points) and a detailed version with cases, citations, and source page ranges. This replaces the “build a framework first” consulting step.

Every H2 category must have at least 2 H3 topics. This prevents hierarchy from being a facade — if you can’t break a category into at least two subtopics, your schema isn’t well-formed.

Phase 2: File Structure

Batch creates folders, Maps of Content (MOC) pages, and blank atomic note stubs. The MOC sits at the H3 level, ensuring every cluster of notes has an index page.

Phase 3: Parallel Content Fill (The Interesting Part)

This is where the engineering gets specific. Each atomic note goes through a five-state state machine:

draft → filling → filled → reviewed
                  ↘ needs_review

The filling state splits into two failure modes: crash before the .md.tmp is written (no partial file) and crash after the rename but before metadata update (.md exists but status is still filling). Both are handled differently during recovery.

A Python script (context-extractor.py) pre-fetches source paragraphs per source_range. Instead of passing the full PDF to every agent, each note agent receives only the pages it needs. Token consumption drops 5x or more.

After filling, every note is reviewed by a separate agent. If it fails quality thresholds — 200+ words for core concepts, 150+ for case studies, 50-150 for original citations — it gets up to 2 repair attempts before being flagged as needs_review.

Phase 4: Wikilink Building

A three-stage funnel:

  1. Structural affinity (zero cost) — notes in the same H2 category get linked
  2. TF-IDF semantics (low cost) — pure Python, zero external dependencies, scores term overlap
  3. LLM classification (controlled cost) — five relationship types: derivation, analogy, contradiction, application, context

Most existing wikilink tools stop at “similar words match.” The five-type classification maps to Bloom’s taxonomy levels from comprehension to evaluation.

Phase 5: Core Questions

Generates ≤5 guiding questions per topic. These anchor the learner’s retrieval practice — not “what does this term mean” but “how would you apply this principle to a situation where X and Y conflict?”

Phase 6: Deep Research

Web search for controversy analysis. Output uses a three-layer framework: consensus, disputes, and context-dependent. This is the closest the pipeline gets to the “expert interview” consulting step.

The Engineering Decisions That Matter

Three design choices separate VaultForge from most “AI agent” projects.

Atomic writes. Every note is written as .md.tmp first, verified on disk, then renamed. If the agent crashes mid-write — which happens constantly — the partial file is invisible to Obsidian and detectable on resume.

File-based statistics. Phase 1 counts roadmap topics by parsing the output file, not by asking the model “how many did you write.” Model self-report is unreliable. Deterministic file parsing is not.

English instructions, bilingual output. All agent instructions are in English because LLMs execute English prompts with highest precision. But the first user prompt asks for language selection. The templates support English and Chinese section headers side by side. It’s a small thing that matters a lot for non-English users.

What the Cognitive Science Evaluation Said

I had a final evaluation done against cognitive science standards. The key findings:

Design Element Principle Score
Atomic notes + length rules Cognitive Load Theory — chunking in both directions 5/5
MOC at H3 level Ausubel’s advance organizers + Schema Theory 5/5
Five-type wikilinks Constructivist semantic network + Bloom’s 4.5/5
Core questions (≤5) Elaborative Interrogation 4/5
Controversy analysis Piaget cognitive conflict + critical thinking 5/5
H2 ≥ 2 H3 constraint Taxonomy minimum branching 4/5

The overall judgment: “VaultForge is the project with the most solid educational theory and the most rigorous engineering architecture in the current Agent Skill ecosystem.”

I don’t disagree, but I’ll let readers decide by examining the repo.

Limits and Next Steps

It’s not for everything. VaultForge works best with structured, high-quality materials — textbooks, academic papers, industry reports. It’s unsuited for fragmented reading like news feeds or social media. For that, a different tool is needed.

The biggest missing piece: incremental updates. If you add a new PDF to a processed vault, the current version requires re-running the full pipeline. Incremental mode is the next major feature.

And the evaluation identified one clear upgrade path: adding a retrieval practice feedback loop — answer input, LLM assessment, weakness tagging, spaced repetition scheduling. That would take the educational dimension from 4.5/5 to 5/5.

How to Use It

git clone https://github.com/Easonnotsing/VaultForge.git ~/.agents/skills/VaultForge
pip install pypdf

Then trigger in any Claude Code / Codex / Cursor session. That’s it. The skill handles the rest.

The repo has 51 automated tests, a compatibility guide for different clients, and a changelog of every failure pattern encountered during development.

The Larger Pattern

Tools will keep accelerating. What matters isn’t which note-taking app you use or which model you call. What matters is what happens in your head while you use them.

I built VaultForge because I wanted my students to spend their limited time thinking — not formatting, not organizing, not wrestling with folder structures. The AI handles the production work. The human handles the judgment.

That division of labor is going to become the default. This is one version of what it looks like.

VaultForge is open source under MIT. Repo: https://github.com/Easonnotsing/VaultForge

Your AI database agent needs better errors than “tool failed”

“Tool failed” is not an error message.

It is a debugging tax.

When an AI agent queries a database through MCP, failures need to be structured enough for the agent to recover and specific enough for humans to audit.

A database tool can fail because:

  • user lacks tenant or role scope
  • query exceeds row/time/cost budgets
  • requested metric is not approved
  • data source is stale or unavailable
  • result was partially returned
  • write requires approval before execution

Those should not all collapse into the same generic error.

A useful MCP database error explains:

  • what happened
  • what was protected
  • whether the request was denied, partial, stale, redacted, or too broad
  • what narrower/safe retry is allowed
  • the audit ID for review

Longer version: MCP tool errors for AI database agents

The happy path demos well. The unhappy path is what makes the system operable.

Your MCP database server should not use an admin key

The fastest way to make an AI database agent dangerous is to connect it with the same credential a senior engineer uses in production.

The model does not need your admin key.

It needs a narrow, explicit operating lane.

A safer MCP database setup starts with the job:

  • answer product analytics questions
  • inspect support tickets
  • summarize operational metrics
  • prepare a write action for human approval

Each job deserves its own credential scope.

Read-only should be the default. Usually against approved views, not raw application tables.

Writes need a different lane entirely:

  • separate role
  • separate tool
  • dry-run preview
  • rows/objects affected
  • approval requirement
  • audit receipt after execution

Longer version: Scoped credentials for MCP database servers

The practical rule:

Do not expose a database connection first and decide policy later. Define the job, then issue the smallest credential that can do that job.

CDN Cache Invalidation: Why Deleted URLs Still Redirect (And How We Fixed It)

This article was originally published on Jo4 Blog.

You deleted the URL. Redis says it’s gone. The database confirms it. But users click the link and still get redirected to the destination. cf-cache-status: HIT. Cloudflare is happily serving a cached copy that nobody told it to forget.

The Problem

We run a URL shortener behind Cloudflare CDN. For performance, we cache redirect responses at the edge with a 2-hour TTL. This means popular short URLs resolve in under 50ms globally without touching our origin.

The issue surfaced when a customer deleted a short URL and then clicked it to verify. Still working. They tried again 30 minutes later. Still working. They opened a support ticket.

Same story with URL updates. A user changed the destination from https://old-site.com to https://new-site.com. The short URL kept redirecting to the old destination. OG metadata updates had the same problem — social cards showed stale titles and images because the HTML page was cached at the edge.

Three distinct mutations, all broken:

  1. Delete URL — link stays alive via CDN cache
  2. Update URL — old destination served from CDN, old metadata in Redis
  3. Admin force-expire — neither Redis nor CDN gets invalidated

The Root Cause: Layered Caching, Partial Invalidation

Our caching architecture has two layers:

User Request → Cloudflare CDN (edge cache) → Spring Boot API → Redis (app cache) → PostgreSQL

When a URL is deleted, the service correctly invalidated the Redis cache. But it never told Cloudflare. The CDN layer continued serving stale responses until the TTL expired naturally.

The update path was worse. UrlService.updateUrl() wrote the new destination to the database but invalidated neither Redis nor Cloudflare. Reads hit Redis first, got the old cached value, and never saw the database update.

Admin operations were the worst. AdminService.forceExpireUrl() and AdminService.deleteUrl() updated the database directly and skipped both cache layers entirely. Admin code had been written as direct repository calls, bypassing the service-layer cache invalidation that regular user operations went through.

The Fix: Purge Both Layers on Every Mutation

Step 1: Add purgeUrls() to CloudflareService

Cloudflare exposes POST /zones/{zone_id}/purge_cache with a {"files": [...]} body. We wrapped it in a service method:

@Async
public void purgeUrls(List<String> urls) {
    if (!isEnabled() || urls == null || urls.isEmpty()) {
        return;
    }

    // Cloudflare API allows max 30 URLs per purge request
    List<List<String>> batches = partition(urls, 30);
    for (List<String> batch : batches) {
        purgeUrlBatch(batch);
    }
}

private void purgeUrlBatch(List<String> urls) {
    String endpoint = CLOUDFLARE_API_BASE + "/zones/" + zoneId + "/purge_cache";
    try {
        HttpHeaders headers = createHeaders();
        Map<String, Object> body = Map.of("files", urls);
        HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);

        ResponseEntity<CloudflareResponse> response = restTemplate.exchange(
            endpoint, HttpMethod.POST, request, CloudflareResponse.class
        );

        if (response.getBody() != null && response.getBody().isSuccess()) {
            log.info("Purged {} URL(s) from Cloudflare cache", urls.size());
        } else {
            log.warn("Cloudflare cache purge returned non-success for URLs: {}", urls);
        }
    } catch (Exception e) {
        log.warn("Failed to purge Cloudflare cache: {}", e.getMessage());
    }
}

Two design decisions here:

@Async (fire-and-forget). CDN purge should never block the user operation. If Cloudflare is slow or down, the delete/update still completes instantly. The cache will expire naturally via TTL as a fallback.

Batched in groups of 30. Cloudflare’s API limits purge requests to 30 URLs per call. A single short URL can produce up to 3 cacheable URLs (UI page, API endpoint, custom domain), so this limit matters for bulk operations.

Step 2: Build the List of Cacheable URLs

Each short URL can be cached under multiple paths. We need to purge all of them:

private void addCacheableUrls(List<String> urls, String shortUrl, String customDomain) {
    // UI page (HTML with OG tags, served via Cloudflare CDN)
    urls.add(uiHost + "/a/" + shortUrl);
    // API endpoint (JSON, also cached by Cloudflare)
    urls.add(apiHost + "/api/v1/public/a/" + shortUrl);
    // Custom domain URL (if configured)
    if (StringUtils.isNotBlank(customDomain)) {
        urls.add("https://" + customDomain + "/a/" + shortUrl);
    }
}

For updates where the short URL or custom domain itself changed, we purge both old and new URLs:

private void purgeCloudflareCache(String shortUrl, String customDomain,
                                   String oldShortUrl, String oldCustomDomain) {
    List<String> urlsToPurge = new ArrayList<>();
    addCacheableUrls(urlsToPurge, shortUrl, customDomain);

    if (oldShortUrl != null && !oldShortUrl.equals(shortUrl)) {
        addCacheableUrls(urlsToPurge, oldShortUrl, oldCustomDomain);
    } else if (oldCustomDomain != null && !oldCustomDomain.equals(customDomain)) {
        urlsToPurge.add("https://" + oldCustomDomain + "/a/" + shortUrl);
    }

    if (!urlsToPurge.isEmpty()) {
        cloudflareService.purgeUrls(urlsToPurge);
    }
}

Step 3: Wire Into Every Mutation Path

This is where the original bug lived. We had to audit every code path that mutates URL state:

UrlService (user-facing operations):

  • updateUrl() — added Redis invalidation + Cloudflare purge
  • deleteUrl() — already had Redis invalidation, added Cloudflare purge

AdminService (admin operations):

  • forceExpireUrl() — added both Redis + Cloudflare invalidation
  • deleteUrl() — added both Redis + Cloudflare invalidation
  • refreshMetadata() — added Cloudflare purge (OG tags changed)

The admin fix required a dedicated helper since admin code was calling repositories directly:

private void invalidateUrlCaches(UrlEntity url) {
    urlCacheService.invalidateCache(url.getShortUrl());

    List<String> urlsToPurge = new ArrayList<>();
    urlsToPurge.add(uiHost + "/a/" + url.getShortUrl());
    urlsToPurge.add(apiHost + "/api/v1/public/a/" + url.getShortUrl());
    if (StringUtils.isNotBlank(url.getCustomDomain())) {
        urlsToPurge.add("https://" + url.getCustomDomain() + "/a/" + url.getShortUrl());
    }
    cloudflareService.purgeUrls(urlsToPurge);
}

One method. Both cache layers. Called from every admin mutation.

Why @Async Is the Right Call

CDN purge is a network call to Cloudflare’s API. It adds 100-300ms of latency. If we made it synchronous:

  • User deletes a URL — waits an extra 200ms for Cloudflare confirmation
  • Cloudflare API is down — user’s delete fails or hangs
  • Bulk operations — each URL adds another round-trip

With @Async, the user operation completes immediately. The purge runs in the background thread pool. If it fails, the cache expires naturally via TTL (2 hours max). The user never notices.

The tradeoff: there’s a brief window (milliseconds to seconds) where the CDN might still serve stale content after an update. For a URL shortener, this is acceptable. For something like financial data, you’d want synchronous purge with error handling.

Lessons Learned

1. Cache invalidation has layers. If your architecture has CDN → Redis → Database, you need to invalidate from the outside in. Clearing Redis doesn’t help if Cloudflare is still serving cached responses. Most requests never reach your app server when the CDN has a hit.

2. Admin code is a blind spot. Admin operations often bypass service-layer abstractions. They call repositories directly for flexibility, but that means they skip whatever cache invalidation the service layer provides. Audit every mutation path, not just the user-facing ones.

3. Fire-and-forget is correct for CDN purge. Don’t block user operations on external API calls. Use @Async, log failures, and rely on TTL expiration as your safety net. The worst case is stale content for a bounded time window.

4. Enumerate all cacheable URLs. A single logical resource can exist at multiple CDN URLs. Miss one and you have a partial purge. Our short URLs have three: the UI page, the API endpoint, and the custom domain variant. All three need purging.

Ever been bitten by a stale CDN cache hiding a “deleted” resource? What’s your cache invalidation strategy?

Building jo4.io – URL shortener with analytics, custom domains, and team workspaces.

AI coding agents need browser evidence, not just code inspection

The strongest signal in today’s Doramagic GitHub metrics was not an abstract agent framework. It was a practical browser-verification pack:

https://github.com/tangweigang-jpg/doramagic-chrome-devtools-mcp-pack

In the 2026-05-20 sample, it had 120 clones, 60 unique cloners, and 44 views. The paths included traffic and commit-activity pages, which suggests readers were checking whether the pack was real enough to reuse.

That signal matters because AI coding agents often fail at the same boundary: they can edit code, but they cannot always prove that the browser experience works.

Common failure modes:

  • the page is blank, but the agent says it is done;
  • tests pass, but the button cannot be clicked;
  • console errors are ignored;
  • network failures are not connected to the code change;
  • screenshots exist, but no acceptance criteria are checked.

Chrome DevTools MCP-style access gives an agent browser evidence: console output, network behavior, DOM state, and real interaction results.

But a tool is not yet a capability asset. A reusable pack should tell the host agent:

  1. when browser evidence is required;
  2. what evidence must be recorded;
  3. when to stop instead of guessing;
  4. how to connect browser failure back to a code change.

A useful loop looks like this:

open the target URL
-> inspect console errors
-> run the key interaction
-> record the visible result
-> compare against acceptance criteria
-> only then edit code or report success

This is an unofficial AI capability pack prepared by Doramagic. Unless the upstream project explicitly says otherwise, it is not an official upstream release.

A YAML-Driven Multi-Channel RestClient — The Story of Building mido-client

I built a Spring Boot 3.2+ open-source library that lets you declare and manage external API channels entirely from YAML.

It started in the travel industry

The thing is, every OTA has different API specs. One pattern that shows up often is when the lookup host and the booking/payment host are separated.

  • https://api-search.someota.com → product lookup, price check
  • https://api-booking.someota.com → actual booking and payment

Same OTA, but two endpoints — different auth tokens, different timeout policies.
Once you have 10 or 20 such OTAs, the problem starts in earnest.
Similar-looking @Bean declarations grow with the channel count — and double again for any channel with dual endpoints — honestly piling up.

The limits of RestClient + @bean

My first attempt was the obvious one: register RestClient (introduced in Spring Boot 3.2) as @Beans. It was boilerplate hell.

@Configuration
public class OtaClientConfig {

    @Bean("abcOtaSearchClient")
    public RestClient abcOtaSearchClient() {
        return buildClient(
            "https://api-search.someota.com",
            System.getenv("ABC_SEARCH_TOKEN"),
            Duration.ofSeconds(30),
            Duration.ofSeconds(5)
        );
    }

    @Bean("abcOtaBookingClient")
    public RestClient abcOtaBookingClient() {
        return buildClient(
            "https://api-booking.someota.com",
            System.getenv("ABC_BOOKING_TOKEN"),
            Duration.ofSeconds(60),
            Duration.ofSeconds(5)
        );
    }

    // Two more beans for XYZ OTA
    // Two more beans for DEF OTA
    // ... 10 channels means 20 beans

    private RestClient buildClient(String baseUrl, String token,
                                    Duration readTimeout, Duration connectTimeout) {
        // factory, interceptor, logging setup...
    }
}

Sure, you can extract a buildClient helper. But the more channels you add, the more @Bean methods pile up.

Why not OpenFeign?

OpenFeign was on the table, of course. Clean abstraction — just interfaces and annotations. But when I actually tried it, things kept getting in the way.

1. It doesn’t sit on top of Spring Boot 3.2’s RestClient.
OpenFeign runs on Feign internally. If your org wants a unified HTTP client policy built on RestClient, that consistency breaks the moment you adopt Feign.

2. Mapping two endpoints to one service is awkward.
The dual-host OTA pattern — lookup and booking on different URLs — forces you to split into two Feign interfaces. “It’s the same OTA, why am I splitting it into two classes?” That nagging feeling never goes away.

3. Customization is shallow.
Shaping request/response logging into a specific format (URL + body + response time + status) means bringing in a plugin or reconfiguring Logger.Level. Details like gzip compression or decompression-bomb defense? You write those yourself. I spent days just matching the log format ops required.

4. The code grows back anyway.
Interfaces, fallbacks, configuration classes, ErrorDecoder, RequestInterceptor — what the annotations seemed to save up front, you end up paying back elsewhere.

What I wanted was simple: “if I look at one YAML, I can see exactly how this system talks to the outside world.” OpenFeign doesn’t get you there.

So I built it myself — mido-client

I searched existing open-source options for a while. A few came close in concept, but none of them ticked every box. So I built it myself. Its name is mido-client.

The core idea is simple.

Declare external API channels in YAML. Let code focus on business logic.

Comparison

RestClient (vanilla) OpenFeign mido-client
Configuration style Java @Bean Interface + annotations YAML only
Multi-channel setup Manual per bean Manual per interface Built-in
Dual endpoint per service Manual Not supported Built-in (primary / secondary)
Request/response logging DIY Plugin required Built-in (4 levels)
Client caching Manual Managed by framework Built-in (ConcurrentHashMap)
Based on Spring Boot 3.2+ Feign Spring Boot 3.2+ RestClient

How the code shrinks

Before — 4 beans + a config class.
After — one block in application.yml.

mido-client:
  enabled: true
  channels:
    abcOta:
      title: "ABC OTA"
      charset: UTF-8
      primary:                            # lookup endpoint
        url: https://api-search.someota.com
        read-timeout-seconds: 30
        connect-timeout-seconds: 5
        authorization:
          type: bearer
          token: ${ABC_SEARCH_TOKEN}
        log: console
      secondary:                          # booking endpoint
        url: https://api-booking.someota.com
        read-timeout-seconds: 60
        authorization:
          type: bearer
          token: ${ABC_BOOKING_TOKEN}
        log: all                          # console + file
    xyzOta:
      primary:
        url: https://api.xyzota.com
        authorization:
          type: bearer
          token: ${XYZ_TOKEN}

The service code looks like this:

@Service
public class AbcOtaService extends BaseExternalApi {

    private final RestClient searchClient;
    private final RestClient bookingClient;

    public AbcOtaService(MidoClientFactory midoClientFactory) {
        this.searchClient  = midoClientFactory.getOrCreateClient("abcOta");
        this.bookingClient = midoClientFactory.getOrCreateClient("abcOta", EndpointType.SECOND);
    }

    @Override
    protected String getChannelName() {
        return "abcOta";
    }

    public HotelList searchHotels(SearchCondition cond) {
        return withDefaultChannelAction("searchHotels", () ->
            searchClient.post()
                .uri("/hotels/search")
                .body(cond)
                .retrieve()
                .body(HotelList.class)
        );
    }

    public BookingResult book(BookingRequest req) {
        return withDefaultChannelAction("book", () ->
            bookingClient.post()
                .uri("/bookings")
                .body(req)
                .retrieve()
                .body(BookingResult.class)
        );
    }
}

No @Bean methods. No factory classes. No proliferating interfaces. Want to add another channel? Add one more block in YAML.

Details worth showing off

1. Four-level logging — ops will thank you

log: off       # off
log: console   # console only
log: file      # file only (MidoClientFileLog logger)
log: all       # console + file simultaneously

Each log line includes the request URL, method, request/response body, response time, and status. Whenever ops asks “trace this OTA request and tell me where it came from,” you no longer need to touch the logging code.

2. ChannelContext + MDC — distributed log tracing

A single withDefaultChannelAction("methodName", ...) call automatically writes channelAction into SLF4J MDC.

<pattern>%d [%X{channelAction}] %-5level %msg%n</pattern>

With a single log line, you can immediately see which channel and which action it broke at. Even when business logic throws an exception, BaseExternalApi handles it so the context is automatically cleared in finally.

Why not ScopedValue?
ScopedValue (JEP 446 / JDK 21+) is the modern way to do context propagation, but mido-client targets Java 17 as its minimum — so we went with ThreadLocal for now. I personally run Java 25, but that’s too bleeding-edge to force on library users. We plan to migrate to ScopedValue when we bump the library’s minimum Java version.

3. Gzip — compression + decompression-bomb defense

gzip:
  request: true
  response: true
  min-size: 1024                   # skip compression for bodies < 1KB
  max-decompressed-size: 10485760  # IOException if response > 10MB after decompression

Request compression, automatic response decompression, and a decompression-bomb defense cap baked in. Some OTAs occasionally return abnormally inflated responses, so having a memory cap is genuinely reassuring.

4. Custom interceptors registered in YAML

Want to plug in Resilience4j? Just put the class name in interceptors:.

interceptors:
  - "com.yourapp.PaymentResilienceInterceptor"

mido-client deliberately does NOT bundle a resilience layer. Each team’s preferred tool (Resilience4j, Sentinel, Spring Retry, Failsafe…) plugs in via this hook. The README ships with a full Resilience4j integration recipe.

5. Fail-fast config validation

@Validated validates @ConfigurationProperties. If a URL is blank, a timeout is negative, or a required primary endpoint is missing, it throws a runtime error.

6. JSON / XML channel type

If you have a legacy SOAP OTA still hanging around — yes, those happen — just declare type: xml on the channel and Content-Type: application/xml is attached automatically.

channels:
  legacySoap:
    type: xml
    primary:
      url: https://soap.example.com

7. Multi-header support

You can register multiple static headers per endpoint. Values that need to ride along on every request — auth tokens, tracing IDs, API versions — can all be collected in one place in YAML.

headers:
  - name: X-AUTH-TOKEN
    value: ${AUTH_TOKEN}
  - name: X-API-Version
    value: v1

Getting started

dependencies {
    implementation 'io.github.skaca8:mido-client:1.0.8'
}
mido-client:
  enabled: true
  channels:
    yourChannel:
      first:
        url: https://api.example.com

These two blocks are all you need. In addition to Maven Central, JitPack-based GitHub dependency is also supported.

Closing

  • Code shouldn’t grow just because channels grow.
  • Open application.yml once, and you can see — at a glance — where and how this service talks to the outside world.

Honestly, I built this library to scratch my own itch, so I hope it helps others working in similar environments. If you’ve ever watched your bean count balloon as you added one more channel, or thought “OpenFeign feels a bit heavy for what I need,” give it a try. Feedback, issues, and PRs are all welcome.

P.S. The library’s name mido is the name of my Maltese, who has been living with me for 13 years.