Drone ACL Test

Drone ACL Test

Imagen: Infra as Code

¿Alguna vez desplegaste una aplicación en Kubernetes y simplemente no se conectaba a un servicio externo? Base de datos, API de terceros, servicio interno en otro namespace, todo configurado correctamente, pero nada funcionaba. Ahí empieza esa danza: “¿será el NetworkPolicy?”, “¿será el firewall?”, “¿será el security group?”, “¿será esto?” o “¿será aquello?” y ahí empieza el infierno en la vida de un DevOps.

Validar ACLs (Access Control Lists) en entornos Kubernetes es un problema que todo profesional de infra conoce. El problema no es solo probar una vez, es garantizar que la conectividad siga funcionando después de cambios de red, actualizaciones de policies o migraciones de workloads.

En este post, voy a presentar un enfoque práctico y automatizado que creé para probar ACLs en K8s usando el proyecto Drone, un pod basado en Ansible que valida conectividad TCP, códigos HTTP y contenido de respuestas. Todo declarativo, todo versionable, todo dentro del clúster.

Validar red en Kubernetes es tedioso y complejo

En entornos tradicionales, probar una ACL era relativamente simple: telnet host puerto, curl URL, listo. En Kubernetes, la historia cambia porque existen múltiples capas de red involucradas:

  • NetworkPolicies — reglas de firewall nativas de K8s que controlan el tráfico entre pods y hacia el mundo externo
  • Service Mesh / Sidecars — Istio, Linkerd y similares que pueden interceptar y bloquear tráfico
  • CNI plugins — Calico, Cilium, Flannel, cada uno con sus particularidades
  • Cloud provider firewalls — Security Groups (AWS), NSGs (Azure), Firewall Rules (GCP)
  • Proxies y NAT — reglas de salida que cambian el comportamiento esperado

¿El resultado? Una prueba que funciona en tu máquina local puede fallar desde dentro del pod. Y peor, fallar de forma silenciosa. La aplicación simplemente da timeout y te quedas buscando el problema en cinco capas diferentes, es simplemente un infierno.

La solución ideal es probar desde dentro del clúster, simulando exactamente el camino de red que la aplicación real va a recorrer.

Drone: Un POD de pruebas basado en Ansible

El Drone es un proyecto open source que resuelve este problema de forma elegante. La idea es simple: un pod con una imagen basada en Ansible que recibe “mapas de prueba” en YAML y valida la conectividad.

Soporta tres tipos de validación:

1. Prueba de ACL (conectividad TCP)

La más básica y esencial. Prueba si un puerto TCP está accesible desde el pod:

acl_tests:
  - ip: "8.8.8.8"
    protocol: tcp
    port: 53
  - ip: "10.0.5.20"
    protocol: tcp
    port: 5432

Usa el módulo ansible.builtin.wait_for para intentar la conexión TCP. Si el puerto responde dentro del timeout, está OK. Si no, FAIL.

2. Prueba de HTTP Code

Valida si una URL devuelve el status code esperado:

http_code_tests:
  - url: "https://api.interna.com/health"
    expected_code: 200
  - url: "https://google.com"
    expected_code: 301

Usa el módulo ansible.builtin.uri para hacer la petición y comparar el código de retorno.

3. Prueba de String (contenido de la respuesta)

Va más allá del status code y valida si el cuerpo de la respuesta contiene un texto específico:

string_tests:
  - url: "https://api.interna.com/health"
    expected_string: "status: healthy"
  - url: "https://example.com"
    expected_string: "Example Domain"

Este tipo de prueba es útil para detectar proxies transparentes que devuelven 200 pero con una página de bloqueo o autenticación.

Arquitectura: Cómo funciona en la práctica

La estructura del proyecto es minimalista y bien organizada:

drone/
├── Vagrantfile              # VM con k3s para dev/pruebas
├── Dockerfile               # Imagem do Drone (Ansible)
├── scripts/
│   ├── bootstrap.sh         # Provisiona la VM (instala k3s)
│   └── deploy.sh            # Deploy do Drone no cluster
├── k8s/
│   ├── namespace.yml        # Namespace dedicado
│   ├── job.yml              # Job que executa os testes
│   └── cronjob.yml          # CronJob para testes periódicos
├── ansible/
│   ├── playbook.yml         # Playbook de testes
│   └── inventory/hosts      # Localhost (execução local no pod)
└── tests/
    ├── acl-map.yml          # Mapa de testes TCP
    ├── http-code-map.yml    # Mapa de testes HTTP
    └── string-map.yml       # Mapa de testes de string

El flujo es directo:

  1. Defines tus pruebas en los archivos YAML dentro de tests/
  2. El deploy.sh crea un ConfigMap con esos archivos y aplica el Job en el clúster
  3. El Job levanta el pod con la imagen Ansible, monta el ConfigMap y ejecuta el playbook
  4. El resultado queda en los logs del Job

La salida es limpia y fácil de parsear:

OK   | tcp        | 8.8.8.8:53
FAIL | tcp        | 10.0.0.1:3306
OK   | http_code  | https://google.com        | expected=301 got=301
FAIL | http_code  | https://example.com:8080  | expected=200 got=timeout
OK   | string     | https://example.com       | search="Example Domain"

Setup: De cero a la primera prueba en 5 minutos

El proyecto incluye un Vagrantfile que levanta una VM con k3s, así que puedes probar localmente sin necesidad de un clúster real.

Levanta la VM con k3s instalado

# Sobe a VM com k3s instalado
vagrant up
Bringing machine 'default' up with 'virtualbox' provider...
==> default: Importing base box 'ubuntu/jammy64'...
==> default: Matching MAC address for NAT networking...
==> default: Checking if box 'ubuntu/jammy64' version '20241002.0.0' is up to date...
==> default: Setting the name of the VM: drone-acl
==> default: Clearing any previously set network interfaces...
==> default: Preparing network interfaces based on configuration...
    default: Adapter 1: nat
    default: Adapter 2: hostonly
==> default: Forwarding ports...
    default: 22 (guest) => 2222 (host) (adapter 1)
==> default: Running 'pre-boot' VM customizations...
==> default: Booting VM...
==> default: Waiting for machine to boot. This may take a few minutes...
    default: SSH address: 127.0.0.1:2222
    default: SSH username: vagrant
    default: SSH auth method: private key
    default: 
    default: Vagrant insecure key detected. Vagrant will automatically replace
    default: this with a newly generated keypair for better security.
    default: 
    default: Inserting generated public key within guest...
    default: Removing insecure key from the guest if it's present...
    default: Key inserted! Disconnecting and reconnecting using new SSH key...
==> default: Machine booted and ready!
==> default: Checking for guest additions in VM...
    default: The guest additions on this VM do not match the installed version of
    default: VirtualBox! In most cases this is fine, but in rare cases it can
    default: prevent things such as shared folders from working properly. If you see
    default: shared folder errors, please make sure the guest additions within the
    default: virtual machine match the version of VirtualBox you have installed on
    default: your host and reload your VM.
    default: 
    default: Guest Additions Version: 6.0.0 r127566
    default: VirtualBox Version: 7.1
==> default: Setting hostname...
==> default: Configuring and enabling network interfaces...
==> default: Mounting shared folders...
    default: /home/leoml/dev/pessoal/github/drone-acl => /vagrant
==> default: Running provisioner: shell...
    default: Running: /tmp/vagrant-shell20260726-1701901-swnjco.sh
    default: ==> Updating system...
    default: dpkg-preconfigure: unable to re-open stdin: No such file or directory
    default: Selecting previously unselected package pigz.
(Reading database ... 64284 files and directories currently installed.)
    default: Preparing to unpack .../0-pigz_2.6-1_amd64.deb ...
    default: Unpacking pigz (2.6-1) ...
    default: Selecting previously unselected package bridge-utils.
    default: Preparing to unpack .../1-bridge-utils_1.7-1ubuntu3_amd64.deb ...
    default: Unpacking bridge-utils (1.7-1ubuntu3) ...
    default: Selecting previously unselected package runc.
    default: Preparing to unpack .../2-runc_1.3.4-0ubuntu1~22.04.1_amd64.deb ...
    default: Unpacking runc (1.3.4-0ubuntu1~22.04.1) ...
    default: Selecting previously unselected package containerd.
    default: Preparing to unpack .../3-containerd_2.2.1-0ubuntu1~22.04.2_amd64.deb ...
    default: Unpacking containerd (2.2.1-0ubuntu1~22.04.2) ...
    default: Preparing to unpack .../4-curl_7.81.0-1ubuntu1.25_amd64.deb ...
    default: Unpacking curl (7.81.0-1ubuntu1.25) over (7.81.0-1ubuntu1.21) ...
    default: Preparing to unpack .../5-libcurl4_7.81.0-1ubuntu1.25_amd64.deb ...
    default: Unpacking libcurl4:amd64 (7.81.0-1ubuntu1.25) over (7.81.0-1ubuntu1.21) ...
    default: Selecting previously unselected package dns-root-data.
    default: Preparing to unpack .../6-dns-root-data_2024071801~ubuntu0.22.04.1_all.deb ...
    default: Unpacking dns-root-data (2024071801~ubuntu0.22.04.1) ...
    default: Selecting previously unselected package dnsmasq-base.
    default: Preparing to unpack .../7-dnsmasq-base_2.90-0ubuntu0.22.04.4_amd64.deb ...
    default: Unpacking dnsmasq-base (2.90-0ubuntu0.22.04.4) ...
    default: Selecting previously unselected package docker.io.
    default: Preparing to unpack .../8-docker.io_29.1.3-0ubuntu3~22.04.2_amd64.deb ...
    default: Unpacking docker.io (29.1.3-0ubuntu3~22.04.2) ...
    default: Selecting previously unselected package ubuntu-fan.
    default: Preparing to unpack .../9-ubuntu-fan_0.12.16_all.deb ...
    default: Unpacking ubuntu-fan (0.12.16) ...
    default: Setting up dnsmasq-base (2.90-0ubuntu0.22.04.4) ...
    default: Setting up runc (1.3.4-0ubuntu1~22.04.1) ...
    default: Setting up dns-root-data (2024071801~ubuntu0.22.04.1) ...
    default: Setting up bridge-utils (1.7-1ubuntu3) ...
    default: Setting up pigz (2.6-1) ...
    default: Setting up libcurl4:amd64 (7.81.0-1ubuntu1.25) ...
    default: Setting up curl (7.81.0-1ubuntu1.25) ...
    default: Setting up containerd (2.2.1-0ubuntu1~22.04.2) ...
    default: Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /lib/systemd/system/containerd.service.
    default: Setting up ubuntu-fan (0.12.16) ...
    default: Created symlink /etc/systemd/system/multi-user.target.wants/ubuntu-fan.service → /lib/systemd/system/ubuntu-fan.service.
    default: Setting up docker.io (29.1.3-0ubuntu3~22.04.2) ...
    default: Adding group `docker' (GID 121) ...
    default: Done.
    default: Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /lib/systemd/system/docker.service.
    default: Created symlink /etc/systemd/system/sockets.target.wants/docker.socket → /lib/systemd/system/docker.socket.
    default: Processing triggers for dbus (1.12.20-2ubuntu4.1) ...
    default: Processing triggers for libc-bin (2.35-0ubuntu3.11) ...
    default: Processing triggers for man-db (2.10.2-1) ...
    default: 
    default: Running kernel seems to be up-to-date.
    default: 
    default: No services need to be restarted.
    default: 
    default: No containers need to be restarted.
    default: 
    default: No user sessions are running outdated binaries.
    default: 
    default: No VM guests are running outdated hypervisor (qemu) binaries on this host.
    default: ==> Starting Docker...
    default: ==> Installing k3s (lightweight, no traefik/servicelb)...
    default: [INFO]  Finding release for channel stable
    default: [INFO]  Using v1.36.2+k3s1 as release
    default: [INFO]  Downloading hash https://github.com/k3s-io/k3s/releases/download/v1.36.2%2Bk3s1/sha256sum-amd64.txt
    default: [INFO]  Downloading binary https://github.com/k3s-io/k3s/releases/download/v1.36.2%2Bk3s1/k3s
    default: [INFO]  Verifying binary download
    default: [INFO]  Installing k3s to /usr/local/bin/k3s
    default: [INFO]  Skipping installation of SELinux RPM
    default: [INFO]  Creating /usr/local/bin/kubectl symlink to k3s
    default: [INFO]  Creating /usr/local/bin/crictl symlink to k3s
    default: [INFO]  Skipping /usr/local/bin/ctr symlink to k3s, command exists in PATH at /usr/bin/ctr
    default: [INFO]  Creating killall script /usr/local/bin/k3s-killall.sh
    default: [INFO]  Creating uninstall script /usr/local/bin/k3s-uninstall.sh
    default: [INFO]  env: Creating environment file /etc/systemd/system/k3s.service.env
    default: [INFO]  systemd: Creating service file /etc/systemd/system/k3s.service
    default: [INFO]  systemd: Enabling k3s unit
    default: Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service → /etc/systemd/system/k3s.service.
    default: [INFO]  systemd: Starting k3s
    default: ==> Waiting for k3s to be ready...
    default: ==> Configuring kubectl for vagrant user...
    default: ==> Building Drone image...
    default: DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
    default:             Install the buildx component to build images with BuildKit:
    default:             https://docs.docker.com/go/buildx/
    default: 
    default: Sending build context to Docker daemon  143.9kB
    default: Step 1/5 : FROM python:3.11-alpine
    default: 3.11-alpine: Pulling from library/python
    default: bf3c3ac26c20: Pulling fs layer
    default: 55afa1ecc21d: Pulling fs layer
    default: ac8c8d4d77fa: Pulling fs layer
    default: 70bd0f459252: Pulling fs layer
    default: ac8c8d4d77fa: Download complete
    default: bf3c3ac26c20: Download complete
    default: 4bf0ae87d821: Download complete
    default: bbfd135613a2: Download complete
    default: 70bd0f459252: Download complete
    default: 55afa1ecc21d: Download complete
    default: 55afa1ecc21d: Pull complete
    default: ac8c8d4d77fa: Pull complete
    default: 70bd0f459252: Pull complete
    default: bf3c3ac26c20: Pull complete
    default: Digest: sha256:25976e9d34a0fab1f278cae931f34c8303d97bf0c0d7f85b6b4dcf641d7702a4
    default: Status: Downloaded newer image for python:3.11-alpine
    default:  ---> 25976e9d34a0
    default: Step 2/5 : RUN apk add --no-cache     bash     && pip install --no-cache-dir ansible-core==2.16.0
    default:  ---> Running in f470e0e70c69
    default: (1/1) Installing bash (5.3.9-r1)
    default:   Executing bash-5.3.9-r1.post-install
    default: Executing busybox-1.37.0-r31.trigger
    default: OK: 14.8 MiB in 39 packages
    default: Collecting ansible-core==2.16.0
    default:   Downloading ansible_core-2.16.0-py3-none-any.whl.metadata (6.9 kB)
    default: Collecting jinja2>=3.0.0 (from ansible-core==2.16.0)
    default:   Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB)
    default: Collecting PyYAML>=5.1 (from ansible-core==2.16.0)
    default:   Downloading pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl.metadata (2.4 kB)
    default: Collecting cryptography (from ansible-core==2.16.0)
    default:   Downloading cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl.metadata (4.3 kB)
    default: Requirement already satisfied: packaging in /usr/local/lib/python3.11/site-packages (from ansible-core==2.16.0) (26.2)
    default: Collecting resolvelib<1.1.0,>=0.5.3 (from ansible-core==2.16.0)
    default:   Downloading resolvelib-1.0.1-py2.py3-none-any.whl.metadata (4.0 kB)
    default: Collecting MarkupSafe>=2.0 (from jinja2>=3.0.0->ansible-core==2.16.0)
    default:   Downloading markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl.metadata (2.7 kB)
    default: Collecting cffi>=2.0.0 (from cryptography->ansible-core==2.16.0)
    default:   Downloading cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl.metadata (2.5 kB)
    default: Collecting pycparser (from cffi>=2.0.0->cryptography->ansible-core==2.16.0)
    default:   Downloading pycparser-3.0-py3-none-any.whl.metadata (8.2 kB)
    default: Downloading ansible_core-2.16.0-py3-none-any.whl (2.2 MB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.2/2.2 MB 956.4 kB/s eta 0:00:00
    default: Downloading jinja2-3.1.6-py3-none-any.whl (134 kB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 134.9/134.9 kB 1.0 MB/s eta 0:00:00
    default: Downloading pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl (794 kB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 795.0/795.0 kB 1.2 MB/s eta 0:00:00
    default: Downloading resolvelib-1.0.1-py2.py3-none-any.whl (17 kB)
    default: Downloading cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl (5.0 MB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.0/5.0 MB 1.9 MB/s eta 0:00:00
    default: Downloading cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl (219 kB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 219.4/219.4 kB 2.5 MB/s eta 0:00:00
    default: Downloading markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl (22 kB)
    default: Downloading pycparser-3.0-py3-none-any.whl (48 kB)
    default:    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.2/48.2 kB 2.8 MB/s eta 0:00:00
    default: Installing collected packages: resolvelib, PyYAML, pycparser, MarkupSafe, jinja2, cffi, cryptography, ansible-core
    default: Successfully installed MarkupSafe-3.0.3 PyYAML-6.0.3 ansible-core-2.16.0 cffi-2.1.0 cryptography-49.0.0 jinja2-3.1.6 pycparser-3.0 resolvelib-1.0.1
    default: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
    default: 
    default: [notice] A new release of pip is available: 24.0 -> 26.1.2
    default: [notice] To update, run: pip install --upgrade pip
    default:  ---> Removed intermediate container f470e0e70c69
    default:  ---> 28f19d9fb27c
    default: Step 3/5 : WORKDIR /drone
    default:  ---> Running in 7827fbe558b1
    default:  ---> Removed intermediate container 7827fbe558b1
    default:  ---> 9309390dc1c4
    default: Step 4/5 : COPY ansible/ /drone/ansible/
    default:  ---> 4d1d45af2d59
    default: Step 5/5 : ENTRYPOINT ["ansible-playbook", "/drone/ansible/playbook.yml", "-i", "/drone/ansible/inventory/hosts"]
    default:  ---> Running in 248ba4b45a94
    default:  ---> Removed intermediate container 248ba4b45a94
    default:  ---> cc93d270b27b
    default: Successfully built cc93d270b27b
    default: Successfully tagged drone-acl:latest
    default: docker.io/library/drone acl:latest      	saved
    default: application/vnd.oci.image.manifest.v1+json sha256:cc93d270b27be1a7c71f563cae248780169de04986d4cc3c154c1214bd8f1998
    default: Importing	elapsed: 2.4 s	total:   0.0 B	(0.0 B/s)
    default: ==> Deploying Drone to the cluster...
    default: namespace/drone created
    default: configmap/acl-map created
    default: job.batch/drone-acl-test created
    default: 
    default: ==> Provisioning complete!
    default:     Access with: vagrant ssh
    default:     View results: kubectl -n drone logs job/drone-acl-test

Accede a la VM

# Acessa a VM
vagrant ssh
vagrant@drone-acl:~$ uname -an
Linux drone-acl 5.15.0-163-generic #173-Ubuntu SMP Tue Oct 14 17:51:00 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
vagrant@drone-acl:~$ 

Hace el build de la imagen y el deploy


# Builda a imagem e faz o deploy
cd /vagrant
./scripts/deploy.sh 
==> Building Drone image...
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

Sending build context to Docker daemon  143.9kB
Step 1/5 : FROM python:3.11-alpine
 ---> 25976e9d34a0
Step 2/5 : RUN apk add --no-cache     bash     && pip install --no-cache-dir ansible-core==2.16.0
 ---> Using cache
 ---> 28f19d9fb27c
Step 3/5 : WORKDIR /drone
 ---> Using cache
 ---> 9309390dc1c4
Step 4/5 : COPY ansible/ /drone/ansible/
 ---> Using cache
 ---> 4d1d45af2d59
Step 5/5 : ENTRYPOINT ["ansible-playbook", "/drone/ansible/playbook.yml", "-i", "/drone/ansible/inventory/hosts"]
 ---> Using cache
 ---> cc93d270b27b
Successfully built cc93d270b27b
Successfully tagged drone-acl:latest
==> Importing image into k3s...
docker.io/library/drone acl:latest      	saved	
application/vnd.oci.image.manifest.v1+json sha256:cc93d270b27be1a7c71f563cae248780169de04986d4cc3c154c1214bd8f1998
Importing	elapsed: 1.1 s	total:   0.0 B	(0.0 B/s)	
==> Applying namespace...
namespace/drone unchanged
==> Creating ConfigMap from test files...
configmap/acl-map unchanged
==> Deleting previous job (if exists)...
job.batch "drone-acl-test" deleted from drone namespace
==> Creating new job...
job.batch/drone-acl-test created
==> Waiting for completion...
job.batch/drone-acl-test condition met

==> Results:
[WARNING]: Found variable using reserved name: timeout

PLAY [Drone ACL Test] **********************************************************

TASK [Load ACL test map] *******************************************************
ok: [localhost]

TASK [Run ACL tests] ***********************************************************
[WARNING]: Platform linux on host localhost is using the discovered Python
interpreter at /usr/local/bin/python3.11, but future installation of another
Python interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible-
core/2.16/reference_appendices/interpreter_discovery.html for more information.
ok: [localhost] => (item={'ip': '8.8.8.8', 'protocol': 'tcp', 'port': 53})
ok: [localhost] => (item={'ip': '1.1.1.1', 'protocol': 'tcp', 'port': 443})
ok: [localhost] => (item={'ip': '192.168.56.1', 'protocol': 'tcp', 'port': 22})
failed: [localhost] (item={'ip': '10.0.0.1', 'protocol': 'tcp', 'port': 3306}) => {"ansible_loop_var": "item", "changed": false, "elapsed": 4, "item": {"ip": "10.0.0.1", "port": 3306, "protocol": "tcp"}, "msg": "Timeout when waiting for 10.0.0.1:3306"}
...ignoring

TASK [Display ACL results] *****************************************************
ok: [localhost] => (item=8.8.8.8) => {
    "msg": "OK  | tcp  | 8.8.8.8:53"
}
ok: [localhost] => (item=1.1.1.1) => {
    "msg": "OK  | tcp  | 1.1.1.1:443"
}
ok: [localhost] => (item=192.168.56.1) => {
    "msg": "OK  | tcp  | 192.168.56.1:22"
}
ok: [localhost] => (item=10.0.0.1) => {
    "msg": "FAIL  | tcp  | 10.0.0.1:3306"
}

TASK [Load HTTP code test map] *************************************************
ok: [localhost]

TASK [Run HTTP code tests] *****************************************************
failed: [localhost] (item={'url': 'https://httpbin.org/status/200', 'expected_code': 200}) => {"ansible_facts": {"discovered_interpreter_python": "/usr/local/bin/python3.11"}, "ansible_loop_var": "item", "changed": false, "elapsed": 3, "item": {"expected_code": 200, "url": "https://httpbin.org/status/200"}, "msg": "Status code was -1 and not [200]: Connection failure: The read operation timed out", "redirected": false, "status": -1, "url": "https://httpbin.org/status/200"}
ok: [localhost] => (item={'url': 'https://httpbin.org/status/404', 'expected_code': 404})
failed: [localhost] (item={'url': 'https://google.com', 'expected_code': 301}) => {"accept_ch": "Sec-CH-Prefers-Color-Scheme", "accept_ranges": "none", "alt_svc": "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000", "ansible_loop_var": "item", "cache_control": "private, max-age=0", "changed": false, "connection": "close", "content_security_policy_report_only": "object-src 'none';base-uri 'self';script-src 'nonce-GHpRu7aU1vHoySNpWx0Uaw' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp", "content_type": "text/html; charset=ISO-8859-1", "cookies": {"AEC": "AdJVEatTFam50Rjk-5u4e88Is29r73GaI2uSBjiDERn-4dSuGp8yaJoddK4", "NID": "533=NZQ-euJ1OBCo5t4D258b3cB4KvCPTUcEXVz0rpF2ZLbs1jaebF_xpp_tuMtpWfQVuq18kEzNZECbjq7Cy_UvwT_8w3dwuc7cEDcMQCCb82mlU2i9d9fE4u5OLOiub-UQsDrwy027IL95_HtHb18FE8K-lzt_GTyeqea2WcjQc_MRVnLWDRlXgCS0HifB8CTrKtG462U5N4mDczZAM6quSA", "__Secure-STRP": "ANmZwa3AD5r24-07rE-RvwZ91f7XLbw-1Glr2zsx3foWaT6g4nQjeR17i-4zzcRIbYWOjoP-8qzfuWaWHJ_Mx2TU_Xy3kEFYnlOp"}, "cookies_string": "__Secure-STRP=ANmZwa3AD5r24-07rE-RvwZ91f7XLbw-1Glr2zsx3foWaT6g4nQjeR17i-4zzcRIbYWOjoP-8qzfuWaWHJ_Mx2TU_Xy3kEFYnlOp; AEC=AdJVEatTFam50Rjk-5u4e88Is29r73GaI2uSBjiDERn-4dSuGp8yaJoddK4; NID=533=NZQ-euJ1OBCo5t4D258b3cB4KvCPTUcEXVz0rpF2ZLbs1jaebF_xpp_tuMtpWfQVuq18kEzNZECbjq7Cy_UvwT_8w3dwuc7cEDcMQCCb82mlU2i9d9fE4u5OLOiub-UQsDrwy027IL95_HtHb18FE8K-lzt_GTyeqea2WcjQc_MRVnLWDRlXgCS0HifB8CTrKtG462U5N4mDczZAM6quSA", "date": "Sun, 26 Jul 2026 15:24:58 GMT", "elapsed": 0, "expires": "-1", "item": {"expected_code": 301, "url": "https://google.com"}, "msg": "Status code was 200 and not [301]: OK (unknown bytes)", "p3p": "CP=\"This is not a P3P policy! See g.co/p3phelp for more info.\"", "redirected": true, "server": "gws", "set_cookie": "__Secure-STRP=ANmZwa3AD5r24-07rE-RvwZ91f7XLbw-1Glr2zsx3foWaT6g4nQjeR17i-4zzcRIbYWOjoP-8qzfuWaWHJ_Mx2TU_Xy3kEFYnlOp; expires=Sun, 26-Jul-2026 15:29:58 GMT; path=/; domain=.google.com; Secure; SameSite=strict, AEC=AdJVEatTFam50Rjk-5u4e88Is29r73GaI2uSBjiDERn-4dSuGp8yaJoddK4; expires=Fri, 22-Jan-2027 15:24:58 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax, NID=533=NZQ-euJ1OBCo5t4D258b3cB4KvCPTUcEXVz0rpF2ZLbs1jaebF_xpp_tuMtpWfQVuq18kEzNZECbjq7Cy_UvwT_8w3dwuc7cEDcMQCCb82mlU2i9d9fE4u5OLOiub-UQsDrwy027IL95_HtHb18FE8K-lzt_GTyeqea2WcjQc_MRVnLWDRlXgCS0HifB8CTrKtG462U5N4mDczZAM6quSA; expires=Mon, 25-Jan-2027 15:24:58 GMT; path=/; domain=.google.com; HttpOnly", "status": 200, "transfer_encoding": "chunked", "url": "https://www.google.com/", "vary": "Accept-Encoding", "x_frame_options": "SAMEORIGIN", "x_xss_protection": "0"}
ok: [localhost] => (item={'url': 'https://1.1.1.1', 'expected_code': 200})
...ignoring

TASK [Display HTTP code results] ***********************************************
ok: [localhost] => (item=https://httpbin.org/status/200) => {
    "msg": "FAIL  | http_code  | https://httpbin.org/status/200  | expected=200 got=-1"
}
ok: [localhost] => (item=https://httpbin.org/status/404) => {
    "msg": "OK  | http_code  | https://httpbin.org/status/404  | expected=404 got=404"
}
ok: [localhost] => (item=https://google.com) => {
    "msg": "FAIL  | http_code  | https://google.com  | expected=301 got=200"
}
ok: [localhost] => (item=https://1.1.1.1) => {
    "msg": "OK  | http_code  | https://1.1.1.1  | expected=200 got=200"
}

TASK [Load string test map] ****************************************************
ok: [localhost]

TASK [Run string tests] ********************************************************
failed: [localhost] (item={'url': 'https://httpbin.org/get', 'expected_string': '"url"'}) => {"ansible_facts": {"discovered_interpreter_python": "/usr/local/bin/python3.11"}, "ansible_loop_var": "item", "changed": false, "content": "", "elapsed": 3, "item": {"expected_string": "\"url\"", "url": "https://httpbin.org/get"}, "msg": "Status code was -1 and not [200]: Connection failure: The read operation timed out", "redirected": false, "status": -1, "url": "https://httpbin.org/get"}
ok: [localhost] => (item={'url': 'https://icanhazip.com', 'expected_string': '.'})
ok: [localhost] => (item={'url': 'https://example.com', 'expected_string': 'Example Domain'})
...ignoring

TASK [Display string results] **************************************************
ok: [localhost] => (item=https://httpbin.org/get) => {
    "msg": "FAIL  | string  | https://httpbin.org/get  | search=\"\"url\"\""
}
ok: [localhost] => (item=https://icanhazip.com) => {
    "msg": "OK  | string  | https://icanhazip.com  | search=\".\""
}
ok: [localhost] => (item=https://example.com) => {
    "msg": "OK  | string  | https://example.com  | search=\"Example Domain\""
}

PLAY RECAP *********************************************************************
localhost                  : ok=9    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=3   

# Verificando los resultados de las pruebas

kubectl -n drone logs job/drone-acl-test | grep '"msg": "\(OK\|FAIL\)' | sed 's/.*"msg": "//;s/"$//'
OK  | tcp  | 8.8.8.8:53
OK  | tcp  | 1.1.1.1:443
OK  | tcp  | 192.168.56.1:22
FAIL  | tcp  | 10.0.0.1:3306
OK  | http_code  | https://httpbin.org/status/200  | expected=200 got=200
OK  | http_code  | https://httpbin.org/status/404  | expected=404 got=404
FAIL  | http_code  | https://google.com  | expected=301 got=200
OK  | http_code  | https://1.1.1.1  | expected=200 got=200
OK  | string  | https://httpbin.org/get  | search=\"\"url\"\"
OK  | string  | https://icanhazip.com  | search=\".\"
OK  | string  | https://example.com  | search=\"Example Domain\"

Para usarlo en un clúster existente, basta con buildar la imagen Docker, hacer push a tu registry y ajustar el k8s/job.yml con la referencia correcta de la imagen.

Haciendo pruebas ad-hoc

kubectl -n drone run drone-shell2 --image=drone-acl:latest \
  --image-pull-policy=Never --command -- sleep 3600
pod/drone-shell2 created
vagrant@drone-acl:~$ kubectl -n drone exec drone-shell2 -- \
  ansible-playbook /drone/ansible/playbook.yml \
  -i /drone/ansible/inventory/hosts \
  -e '{"acl_tests":[{"ip":"8.8.8.8","protocol":"tcp","port":53}]}' \
  -e '{"http_code_tests":[]}' \
  -e '{"string_tests":[]}'
[WARNING]: Found variable using reserved name: timeout

PLAY [Drone ACL Test] **********************************************************

TASK [Load ACL test map] *******************************************************
skipping: [localhost]

TASK [Run ACL tests] ***********************************************************
[WARNING]: Platform linux on host localhost is using the discovered Python
interpreter at /usr/local/bin/python3.11, but future installation of another
Python interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible-
core/2.16/reference_appendices/interpreter_discovery.html for more information.
ok: [localhost] => (item={'ip': '8.8.8.8', 'protocol': 'tcp', 'port': 53})

TASK [Display ACL results] *****************************************************
ok: [localhost] => (item=8.8.8.8) => {
    "msg": "OK  | tcp  | 8.8.8.8:53"
}

TASK [Load HTTP code test map] *************************************************
skipping: [localhost]

TASK [Run HTTP code tests] *****************************************************
skipping: [localhost]

TASK [Display HTTP code results] ***********************************************
skipping: [localhost]

TASK [Load string test map] ****************************************************
skipping: [localhost]

TASK [Run string tests] ********************************************************
skipping: [localhost]

TASK [Display string results] **************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=7    rescued=0    ignored=0   

Conclusión

Probar ACLs en Kubernetes no tiene por qué ser el infierno de siempre, manual y frustrante. Con un enfoque declarativo vía mapas de prueba en YAML, ejecución automatizada vía Jobs/CronJobs e integración con pipelines de CI/CD transformas la validación de red en código versionable y auditable. ¡Eso sí que es una ventaja!

El Drone resuelve esto de forma pragmática, es liviano, usa herramientas que la mayoría de los equipos de infra ya conoce (Ansible + kubectl), y se adapta tanto para troubleshooting rápido como para monitoreo continuo. Si trabajas con infraestructura en Kubernetes y todavía validas la conectividad a mano, te sugiero echarle un vistazo al proyecto en GitHub. Las contribuciones son bienvenidas.

La red es la parte que más trabajo da al construir cualquier infraestructura, cuanto antes automatices la validación, menos madrugadas vas a perder debuggeando timeouts del más allá. ;)

¡Un abrazo!

¡Larga y próspera vida a todos!

Referencias


Entre em contato:

NewsLetter - https://engineeringmanager.com.br/
Linkdin - linkedin.com/in/leonardoml/
Twitter: @infraascode_br

Te convido a ver os outros posts do blog Infra-as-Code garanto que tem coisas legais lá!!


--- --- IMPORTANTE --- ---
As opiniões aqui expressas são pessoais e de responsabilidade única e exclusiva do autor, elas não refletem necessariamente a posição das empresas que eu trabalho(ei) e/ou presto(ei) serviço.


bio_banner_test