Drone ACL Test

Image: Infra as Code

Você já fez deploy de uma aplicação em Kubernetes e ela simplesmente não conectava em um serviço externo? Banco de dados, API de terceiros, serviço interno em outra namespace, tudo configurado certinho, mas nada funcionava. Aí começa aquela dança: “será que é o NetworkPolicy?”, “será que é firewall?”, “será que é o security group?”, “será que é isso?” ou “será que é aquilo?” ai começa o inferno no vida de um DevOps.

Validar ACLs (Access Control Lists) em ambientes Kubernetes é um problema que todo profissional de infra conhece. O problema não é só testar uma vez, é garantir que a conectividade continue funcionando após mudanças de rede, atualizações de policies ou migrações de workloads.

Neste post, vou apresentar uma abordagem prática e automatizada que eu creie para testar ACLs em K8s usando o projeto Drone, um pod baseado em Ansible que valida conectividade TCP, códigos HTTP e conteúdo de respostas. Tudo declarativo, tudo versionável, tudo dentro do cluster.

Validar rede em Kubernetes é chato e complexo

Em ambientes tradicionais, testar uma ACL era relativamente simples: telnet host porta, curl URL, pronto. Em Kubernetes, a história muda porque existem múltiplas camadas de rede envolvidas:

  • NetworkPolicies — regras de firewall nativas do K8s que controlam tráfego entre pods e para o mundo externo
  • Service Mesh / Sidecars — Istio, Linkerd e similares que podem interceptar e bloquear tráfego
  • CNI plugins — Calico, Cilium, Flannel, cada um com suas particularidades
  • Cloud provider firewalls — Security Groups (AWS), NSGs (Azure), Firewall Rules (GCP)
  • Proxies e NAT — regras de saída que mudam o comportamento esperado

O resultado? Um teste que funciona na sua máquina local pode falhar de dentro do pod. E pior, falhar de forma silenciosa. A aplicação simplesmente dá timeout e você fica catando o problema em cinco camadas diferentes, é simplesmente um inferno.

A solução ideal é testar de dentro do cluster, simulando exatamente o caminho de rede que a aplicação real vai percorrer.

Drone: Um POD de testes baseado em Ansible

O Drone é um projeto open source que resolve esse problema de forma elegante. A ideia é simples: um pod com uma imagem baseada em Ansible que recebe “mapas de teste” em YAML e valida a conectividade.

Ele suporta três tipos de validação:

1. Teste de ACL (conectividade TCP)

O mais básico e essencial. Testa se uma porta TCP está acessível a partir do pod:

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

Usa o módulo ansible.builtin.wait_for para tentar conexão TCP. Se a porta responder dentro do timeout, está OK. Se não, FAIL.

2. Teste de HTTP Code

Valida se uma URL retorna o status code esperado:

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

Usa o módulo ansible.builtin.uri para fazer a requisição e comparar o código de retorno.

3. Teste de String (conteúdo da resposta)

Vai além do status code e valida se o corpo da resposta contém um texto específico:

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

Esse tipo de teste é útil para detectar proxies transparentes que retornam 200 mas com uma página de bloqueio ou autenticação.

Arquitetura: Como Funciona na Prática

A estrutura do projeto é enxuta e bem organizada:

drone/
├── Vagrantfile              # VM com k3s para dev/testes
├── Dockerfile               # Imagem do Drone (Ansible)
├── scripts/
│   ├── bootstrap.sh         # Provisiona a 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

O fluxo é direto:

  1. Você define seus testes nos arquivos YAML dentro de tests/
  2. O deploy.sh cria um ConfigMap com esses arquivos e aplica o Job no cluster
  3. O Job sobe o pod com a imagem Ansible, monta o ConfigMap e executa o playbook
  4. O resultado fica nos logs do Job

A saída é limpa e 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: Do zero ao primeiro teste em 5 minutos

O projeto inclui um Vagrantfile que sobe uma VM com k3s, então você pode testar localmente sem precisar de um cluster real.

Sobe a VM com 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

Acessa a 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:~$ 

Faz o build da imagem e faz o 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 os resultados dos testes

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 usar em um cluster existente, basta buildar a imagem Docker, fazer push para seu registry e ajustar o k8s/job.yml com a referência correta da imagem.

Fazendo testes 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   

Conclusão

Testar ACLs em Kubernetes não precisa ser o inferno de sempre, manual e frustrante. Com uma abordagem declarativa via mapas de teste em YAML, execução automatizada via Jobs/CronJobs e integração com pipelines de CI/CD você transforma validação de rede em código versionável e auditável. Isso sim é vantagem!!

O Drone resolve isso de forma pragmática, é leve, usa ferramentas que a maioria dos times de infra já conhece (Ansible + kubectl), e se adapta tanto para troubleshooting rápido quanto para monitoramento contínuo. Se você trabalha com infraestrutura em Kubernetes e ainda valida conectividade no braço, sugiro dar uma olhada no projeto no GitHub. Contribuições são bem-vindas.

A rede é a parte que mais dá trabalho para construir qualquer infraestrutura, quanto mais cedo você automatizar a validação, menos madrugadas vai perder debugando timeouts do além!! ;)

Abraços!

Vida longa e próspera a todos!!

Referências


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