diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb35eb9..323065a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,35 @@ jobs: pip install . OminiRTKSync --help ominirtksync --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 ominirtksync:test . + + - name: Validate container with Testcontainers + env: + TEST_DOCKER_IMAGE: ominirtksync: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 bf35d33..0e7e1d7 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: ominirtksync + 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 6d1b56f..aac0878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ classifiers = [ # SAML aparece desabilitada e o painel recusa liga-lo. [project.optional-dependencies] saml = ["python3-saml>=1.16"] +test = [ + "docker>=7.0.0", + "testcontainers>=4.0.0", +] [project.urls] Homepage = "https://github.com/pathbit/OminiRTkSync" diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..d1a28ab --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,127 @@ +"""Validação da imagem Docker do OminiRTkSync 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 (ominirtksync --help) com sucesso dentro do container. +""" + +import os +import time +import unittest +import urllib.request +import urllib.error + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOCKER_IMAGE_PADRAO = "ominirtksync: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 TestOminiRTkSyncContainer(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("OminiRTKSync", 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, "OminiRTKSync") + self.assertTrue( + "OK" in corpo_healthz or "DATABASE_NOT_READY" in corpo_healthz or "GATEWAY_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(["ominirtksync", "--help"]) + self.assertEqual(codigo_saida, 0, f"Comando ominirtksync --help falhou com código {codigo_saida}: {saida}") + texto_saida = saida.decode("utf-8") + self.assertIn("ominirtksync", texto_saida.lower()) + self.assertIn("--daemon", texto_saida) + + +if __name__ == "__main__": + unittest.main()