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 .
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
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
Comment on lines +113 to +115

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 pruning out of the release workflow

Remove this duplicate retention job: .github/workflows/cleanup-packages.yml already listens for completion of Release and Docker Package and runs the same cleanup action with the same policy. Every successful release therefore performs deletion and registry validation twice, while a transient failure in this new job marks an otherwise successfully published release workflow as failed; if it fails, the existing retention workflow then skips its cleanup because it requires a successful release conclusion.

Useful? React with 👍 / 👎.

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
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 = [
# 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"
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 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 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}")
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 the test when image construction fails

When Docker is available but the requested image is absent, this helper attempts to build the Dockerfile; however, any build error is converted into SkipTest, so a local or standalone run of the new container suite reports success-with-skip for exactly the broken Dockerfile it is intended to validate. Only dependency or daemon unavailability should skip the suite—once docker_disponivel() has succeeded, failures from obter_ou_construir_imagem() should fail the test.

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("9RTKSync", 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, "9RTKSync")
self.assertTrue(
"OK" in corpo_healthz or "DATABASE_NOT_READY" in corpo_healthz or "ROUTER_SERVICE_UNREACHABLE" in corpo_healthz,
Comment on lines +111 to +115

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 Assert the deterministic default health response

The container started here has neither /app/data/db/data.sqlite nor a gateway running at its container-local 127.0.0.1, so, as the preceding comment states, the expected result is specifically 503 DATABASE_NOT_READY. Accepting 200 OK or ROUTER_SERVICE_UNREACHABLE allows regressions in the default DB_PATH wiring or database-readiness check to pass this image validation even though the container is reporting the wrong state.

Useful? React with 👍 / 👎.

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()
Loading