Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
paths:
- "src/**"
- "Dockerfile"
- "pyproject.toml"
- ".dockerignore"
tags: ["v*.*.*"]
workflow_dispatch:

Expand Down Expand Up @@ -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
Comment on lines +113 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep package cleanup out of the release success path

Every completed release already triggers the retention action in .github/workflows/cleanup-packages.yml, so this job performs the same destructive cleanup a second time. More importantly, a transient GHCR/API failure here now marks an otherwise successfully built and pushed release as failed; the existing cleanup workflow then skips its own retention job because it requires the release conclusion to be success. Remove this duplicate job and leave retention to the separately serialized workflow.

Useful? React with 👍 / 👎.

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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
127 changes: 127 additions & 0 deletions tests/test_container.py
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +52 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install container-test dependencies before gating releases

When .github/workflows/release.yml discovers this new test, neither testcontainers nor docker has been installed—the workflow only runs plain pip install . after the test step—so this branch skips the entire class and the dependent job can publish a tagged or manually dispatched image without exercising its runtime validation. Install the test extras before the release test step or otherwise make the container-validation job a prerequisite of publishing.

Useful? React with 👍 / 👎.


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}")
Comment on lines +62 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when obtaining or building the image fails

When the requested image is absent and its Dockerfile build fails, this catches the build exception and converts it into SkipTest, so a direct invocation of the new Testcontainers suite exits successfully instead of detecting the broken image. Only unavailable optional infrastructure should be skipped; failures from obter_ou_construir_imagem should propagate as test failures.

Useful? React with 👍 / 👎.


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("<!DOCTYPE html>", 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()
Loading