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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5
with:
python-version: "3.11"
- run: uv sync --extra dev
- run: uv run ruff check ahab.py tests/
- run: uv run ruff format --check ahab.py tests/

unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5
with:
python-version: "3.11"
- run: uv sync --extra dev
- run: uv run pytest --cov --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: coverage-report
path: coverage.xml

integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5
with:
python-version: "3.11"
- run: uv sync --extra dev
- run: docker compose -f docker-compose.test.yml up -d --wait
- run: docker compose -f docker-compose.test.yml exec -T dind docker pull alpine:latest
- run: uv run pytest -m integration -q
- run: docker compose -f docker-compose.test.yml down -v
if: always()
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ __pycache__/
*.egg-info/
dist/
build/
htmlcov/
.coverage
coverage.xml
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,28 @@ ahab --target 10.0.0.1 --port 4243
## Development

```bash
# Run tests
pytest -q tests/
# Install dev dependencies
uv pip install ".[dev]"

# Run unit tests (default, excludes integration)
uv run pytest

# Run with coverage report
uv run pytest --cov --cov-report=term-missing

# Run integration tests (requires Docker daemon on localhost:2375)
uv run pytest -m integration

# Run integration tests with Docker-in-Docker
docker compose -f docker-compose.test.yml up -d --wait
docker compose -f docker-compose.test.yml exec -T dind docker pull alpine:latest
uv run pytest -m integration -q
docker compose -f docker-compose.test.yml down -v

# Lint
ruff check ahab.py
# Run all tests (unit + integration)
uv run pytest -m ""

# Format check
ruff format --check ahab.py
# Lint and format check
uv run ruff check ahab.py tests/
uv run ruff format --check ahab.py tests/
```
15 changes: 15 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
dind:
image: docker:27-dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
ports:
- "2375:2375"

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The Docker daemon port is published as 2375:2375, which binds on all host interfaces by default. Since this is an unauthenticated Docker Remote API endpoint, it should be bound to localhost only (or avoided entirely) to prevent exposing a root-equivalent control socket to the LAN/Wi‑Fi network.

Suggested change
- "2375:2375"
- "127.0.0.1:2375:2375"

Copilot uses AI. Check for mistakes.
healthcheck:
test: ["CMD", "docker", "info"]
interval: 5s
timeout: 3s
retries: 10
tmpfs:
- /var/lib/docker
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
proxy = ["PySocks==1.7.1"]
dev = [
"pytest==8.3.4",
"pytest-cov==6.0.0",
"ruff==0.9.7",
]

Expand All @@ -32,3 +33,20 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"unit: Pure unit tests (no external dependencies)",
"integration: Tests requiring a live Docker daemon",
]
addopts = ["-m", "not integration", "--strict-markers"]

[tool.coverage.run]
source = ["ahab"]
omit = ["tests/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.",
]
35 changes: 35 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared fixtures and automatic test markers for ahab test suite."""

from unittest.mock import MagicMock

import pytest
import requests

from ahab import DockerAPI


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"""Auto-apply 'unit' or 'integration' markers based on file location."""
for item in items:
if "test_integration" in str(item.fspath):
item.add_marker(pytest.mark.integration)
elif not any(item.iter_markers(name="integration")):
item.add_marker(pytest.mark.unit)


@pytest.fixture
def mock_api():
"""A fully-mocked DockerAPI instance (no real HTTP)."""
return MagicMock(spec=DockerAPI)


@pytest.fixture
def docker_api_with_mock_session():
"""A real DockerAPI with its session replaced by a mock.

Use this to test method-level branch logic (status codes, JSON parsing)
without making real HTTP requests.
"""
api = DockerAPI("http://10.0.0.1:2375")
api.session = MagicMock(spec=requests.Session)
return api
Loading