From 8cab572262f5eb0787842dc37ce69b7e48abd2db Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Fri, 18 Sep 2026 09:27:49 -0300 Subject: [PATCH 1/2] feat: adicionar validacao com testcontainers, build no ci e retencao de pacotes --- .github/workflows/ci.yml | 32 +++++++++ .github/workflows/release.yml | 21 ++++++ pyproject.toml | 4 ++ tests/test_container.py | 128 ++++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 tests/test_container.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ff13c..25786f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,35 @@ jobs: pip install . 9RTKSync --help 9rtksync --help + + docker-validation: + name: Validate Docker Image Build & Testcontainers + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Create and activate virtual environment + run: | + python3 -m venv .venv + echo "$GITHUB_WORKSPACE/.venv/bin" >> $GITHUB_PATH + + - name: Install package with test extras + run: | + pip install --upgrade pip + pip install ".[test]" + + - name: Build Docker image (validate no build error) + run: | + docker build -t 9rtksync:test . + + - name: Validate container with Testcontainers + env: + TEST_DOCKER_IMAGE: 9rtksync:test + run: | + PYTHONPATH=src python3 -m unittest tests/test_container.py -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 924e467..f9b766b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,8 @@ on: paths: - "src/**" - "Dockerfile" + - "pyproject.toml" + - ".dockerignore" tags: ["v*.*.*"] workflow_dispatch: @@ -107,3 +109,22 @@ jobs: docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }} docker pull ghcr.io/${{ github.repository }}:latest ``` + + prune-packages: + name: Prune Older Packages (Keep Last 3) + needs: build-and-push-docker + runs-on: ubuntu-latest + steps: + - name: Apply retention policy + uses: dataaxiom/ghcr-cleanup-action@v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + owner: ${{ github.repository_owner }} + packages: 9rtksync + keep-n-tagged: 3 + exclude-tags: latest + delete-untagged: true + delete-ghost-images: true + delete-partial-images: true + delete-orphaned-images: true + validate: true diff --git a/pyproject.toml b/pyproject.toml index 599d605..601b3ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ classifiers = [ # trabalho de biblioteca. Extra opcional: quem so usa OIDC nao carrega nada, e o # codigo importa a biblioteca dentro da funcao. saml = ["python3-saml>=1.16"] +test = [ + "docker>=7.0.0", + "testcontainers>=4.0.0", +] [project.urls] Homepage = "https://github.com/pathbit/9RTKSync" diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..04fcbf6 --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,128 @@ +"""Validação da imagem Docker do 9RTKSync usando Testcontainers. + +Garante que a imagem Docker gerada: +1. Inicializa sem falhas com as configurações padrão. +2. Expõe o dashboard web na porta 9090. +3. Responde requisições HTTP em /login com código 200 e página HTML válida. +4. Responde no endpoint /healthz com o cabeçalho e corpo documentados. +5. Permite executar a CLI (9rtksync --help) com sucesso dentro do container. +""" + +import os +import sys +import time +import unittest +import urllib.request +import urllib.error + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOCKER_IMAGE_PADRAO = "9rtksync:test" + + +def docker_disponivel() -> bool: + try: + import docker + cliente = docker.from_env() + cliente.ping() + return True + except Exception: + return False + + +def obter_ou_construir_imagem(nome_imagem: str) -> str: + import docker + + cliente = docker.from_env() + imagens = cliente.images.list(name=nome_imagem) + if imagens: + return nome_imagem + + # Constrói localmente se não existir pré-construída + dockerfile = os.path.join(RAIZ, "Dockerfile") + if os.path.exists(dockerfile): + imagem, _ = cliente.images.build(path=RAIZ, tag=nome_imagem, rm=True) + return nome_imagem + + raise RuntimeError(f"Imagem {nome_imagem} não encontrada e Dockerfile não localizado em {RAIZ}") + + +class Test9RTKSyncContainer(unittest.TestCase): + @classmethod + def setUpClass(cls): + try: + import testcontainers + import docker + except ImportError: + raise unittest.SkipTest("testcontainers ou docker não estão instalados") + + if not docker_disponivel(): + raise unittest.SkipTest("Docker daemon não está disponível ou acessível") + + cls.imagem = os.environ.get("TEST_DOCKER_IMAGE", DOCKER_IMAGE_PADRAO) + try: + cls.imagem = obter_ou_construir_imagem(cls.imagem) + except Exception as e: + raise unittest.SkipTest(f"Falha ao obter ou construir imagem Docker: {e}") + + def test_container_web_dashboard_e_cli(self): + from testcontainers.core.container import DockerContainer + + with DockerContainer(self.imagem).with_exposed_ports(9090) as container: + host = container.get_container_host_ip() + porta = container.get_exposed_port(9090) + url_login = f"http://{host}:{porta}/login" + url_healthz = f"http://{host}:{porta}/healthz" + + # 1. Aguarda o servidor web aceitar conexões (até 15s) + conectou = False + ultimo_erro = None + for _ in range(15): + time.sleep(1) + try: + with urllib.request.urlopen(url_login, timeout=2) as resp: + if resp.status == 200: + conectou = True + break + except Exception as e: + ultimo_erro = e + + self.assertTrue(conectou, f"Não foi possível conectar ao dashboard web na porta {porta}: {ultimo_erro}") + + # 2. Valida resposta do endpoint /login + with urllib.request.urlopen(url_login, timeout=5) as resp: + self.assertEqual(resp.status, 200) + cabecalhos = dict(resp.getheaders()) + self.assertIn("text/html", cabecalhos.get("Content-Type", "")) + corpo = resp.read().decode("utf-8") + self.assertIn("9RTKSync", corpo) + self.assertIn("", corpo) + + # 3. Valida resposta do endpoint /healthz + try: + with urllib.request.urlopen(url_healthz, timeout=5) as resp: + status_healthz = resp.status + corpo_healthz = resp.read().decode("utf-8") + cabecalho_server = resp.headers.get("Server", "") + except urllib.error.HTTPError as e: + status_healthz = e.code + corpo_healthz = e.read().decode("utf-8") + cabecalho_server = e.headers.get("Server", "") + + # Sem banco montado e sem gateway externo, o healthz esperado é 503 DATABASE_NOT_READY + self.assertIn(status_healthz, (200, 503)) + self.assertEqual(cabecalho_server, "9RTKSync") + self.assertTrue( + "OK" in corpo_healthz or "DATABASE_NOT_READY" in corpo_healthz or "ROUTER_SERVICE_UNREACHABLE" in corpo_healthz, + f"Corpo inesperado do healthz: {corpo_healthz}", + ) + + # 4. Valida execução da CLI dentro do container + codigo_saida, saida = container.exec(["9rtksync", "--help"]) + self.assertEqual(codigo_saida, 0, f"Comando 9rtksync --help falhou com código {codigo_saida}: {saida}") + texto_saida = saida.decode("utf-8") + self.assertIn("9RTKSync", texto_saida) + self.assertIn("--daemon", texto_saida) + + +if __name__ == "__main__": + unittest.main() From 258003620f63daf0bc4fd644e2cf81bd60c58199 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Fri, 18 Sep 2026 09:30:33 -0300 Subject: [PATCH 2/2] chore: remover import nao utilizado de sys em test_container.py --- tests/test_container.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_container.py b/tests/test_container.py index 04fcbf6..e0fda8c 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -9,7 +9,6 @@ """ import os -import sys import time import unittest import urllib.request