Skip to content

Testing Docker in Docker Mode

crocodilestick edited this page Oct 23, 2025 · 1 revision

Docker-in-Docker Testing Mode

Complete guide to running CWA tests inside Docker containers (Docker-in-Docker scenarios).

📋 Table of Contents


The Problem

When running CWA integration tests inside a Docker container (like a dev container), traditional bind mounts fail.

Why Bind Mounts Fail in DinD

┌─────────────────────────────────────┐
│  Dev Container (where tests run)    │
│                                      │
│  Test creates bind mount:            │
│  /tmp/pytest-123/ingest →            │
│  test container:/cwa-book-ingest     │
│                                      │
│  ❌ Problem: Docker daemon runs on   │
│     the HOST, not in dev container   │
│     /tmp/pytest-123 doesn't exist    │
│     on host filesystem!              │
└─────────────────────────────────────┘
         ↓
┌─────────────────────────────────────┐
│  Host Machine (Docker daemon here)   │
│                                      │
│  Docker daemon looks for:            │
│  /tmp/pytest-123/ingest              │
│                                      │
│  ❌ Path doesn't exist on host!      │
│  ✅ Path only exists in dev          │
│     container's filesystem           │
└─────────────────────────────────────┘

Result: Test container sees empty directories, tests fail.


The Solution

Use Docker volumes instead of bind mounts. Docker manages volumes on the host, making them accessible to all containers.

Solution Architecture

┌─────────────────────────────────────┐
│  Dev Container (where tests run)    │
│                                      │
│  Test creates Docker volume:         │
│  docker volume create cwa_test_xxx   │
│                                      │
│  ✅ Volume managed by Docker daemon  │
│     on the host                      │
└─────────────────────────────────────┘
         ↓
┌─────────────────────────────────────┐
│  Host Machine (Docker daemon here)   │
│                                      │
│  Docker daemon creates volume:       │
│  /var/lib/docker/volumes/cwa_test_xxx│
│                                      │
│  ✅ Volume exists on host!           │
│  ✅ Can mount into any container     │
└─────────────────────────────────────┘
         ↓
┌─────────────────────────────────────┐
│  Test Container                      │
│                                      │
│  Volume mounted:                     │
│  cwa_test_xxx → /cwa-book-ingest     │
│                                      │
│  ✅ Files are accessible!            │
└─────────────────────────────────────┘

Quick Usage

Method 1: Interactive Test Runner (Easiest)

./run_tests.sh

The script auto-detects if you're in a container and uses the right mode.

In a dev container, you'll see:

Environment: docker
Default mode: dind (Docker volume mode)

Method 2: Manual Execution

# Set environment variable
export USE_DOCKER_VOLUMES=true

# Run tests
pytest tests/integration/ -v

Or as a one-liner:

USE_DOCKER_VOLUMES=true pytest tests/integration/ -v

Method 3: Make It Default (Optional)

Add to your shell profile (.bashrc, .zshrc, etc.):

# ~/.bashrc or ~/.zshrc
export USE_DOCKER_VOLUMES=true

Then tests always use volume mode:

pytest tests/integration/ -v

How It Works

1. Environment Variable Detection

tests/conftest.py checks for the environment variable:

USE_DOCKER_VOLUMES = os.getenv('USE_DOCKER_VOLUMES', 'false').lower() == 'true'

if USE_DOCKER_VOLUMES:
    # Import Docker volume fixtures
    from tests.conftest_volumes import *

2. Fixture Override

When USE_DOCKER_VOLUMES=true, fixtures from conftest_volumes.py override the default ones:

Fixture Bind Mount Mode Docker Volume Mode
test_volumes Returns Path objects Returns VolumeHelper objects
cwa_container Uses bind mounts Uses volume mounts
ingest_folder Path to temp dir VolumeHelper for volume
library_folder Path to temp dir VolumeHelper for volume

3. VolumeHelper Class

Provides file operations using docker cp:

# Copy file into volume
ingest_folder.copy_to(local_file_path)

# Copy file from volume
library_folder.copy_from("metadata.db", local_dest)

# Check file existence
if ingest_folder.file_exists("book.epub"):
    ...

# List files
files = library_folder.list_files("*.epub")

4. Smart Container Startup

Container readiness detection via log polling:

# Old way: Blind 60-second wait
time.sleep(60)

# New way: Poll logs until ready (~12 seconds)
while not container_ready:
    logs = container.logs().decode('utf-8')
    if 'Starting Calibre Web' in logs:
        break
    time.sleep(1)

Result: Tests start ~48 seconds faster!


VolumeHelper API

The VolumeHelper class provides a Path-like interface for Docker volumes.

File Operations

# Copy files to volume
volume.copy_to(src_path, dest_name=None)
volume.copy_to("/local/book.epub")  # Copies to root of volume
volume.copy_to("/local/book.epub", "renamed.epub")  # Rename on copy

# Copy files from volume
volume.copy_from(src_name, dest_path)
volume.copy_from("metadata.db", "/tmp/metadata.db")

# Check existence
volume.file_exists(filename) → bool
volume.is_dir(path) → bool

Directory Operations

# Create subdirectories
subfolder = volume / "subfolder"
subfolder.mkdir()

# List files
files = volume.list_files()  # All files
files = volume.list_files("*.epub")  # Pattern match

# Iterate directory
for item in volume.iterdir():
    print(item.name)

# Glob patterns
epub_files = volume.glob("**/*.epub")

Path Operations

# Path concatenation
subfolder = volume / "subfolder"
file_path = subfolder / "file.epub"

# VolumePath objects
vol_path = volume / "book.epub"
vol_path.exists() → bool
vol_path.is_dir() → bool
vol_path.name"book.epub"
vol_path.write_text("content")

Database Access

# Extract database to local temp file
db_path = volume.read_to_temp("metadata.db")
conn = sqlite3.connect(db_path)
# Use database...
# File automatically cleaned up

Helper Functions

Available in test code:

# Universal copy (works in both modes)
from tests.conftest import volume_copy
volume_copy(src, dest)  # Auto-detects mode

# Database access (works in both modes)
from tests.conftest import get_db_path
db_path = get_db_path(folder_fixture, "cwa.db")
conn = sqlite3.connect(db_path)

Architecture Details

Dual-Mode Implementation

# tests/conftest.py - Main fixtures (bind mount mode)
@pytest.fixture
def test_volumes(tmp_path):
    """Standard mode: temp directories"""
    ingest = tmp_path / "ingest"
    library = tmp_path / "library"
    return ingest, library

# tests/conftest_volumes.py - Volume mode
@pytest.fixture
def test_volumes_dind():
    """DinD mode: Docker volumes"""
    ingest_vol = VolumeHelper("cwa_test_ingest_xxx")
    library_vol = VolumeHelper("cwa_test_library_xxx")
    yield ingest_vol, library_vol
    # Cleanup volumes
    ingest_vol.cleanup()
    library_vol.cleanup()

Conditional Loading

# tests/conftest.py
USE_DOCKER_VOLUMES = os.getenv('USE_DOCKER_VOLUMES', 'false').lower() == 'true'

if USE_DOCKER_VOLUMES:
    # Override fixtures with volume versions
    from tests.conftest_volumes import (
        test_volumes as test_volumes_dind,
        cwa_container as cwa_container_dind,
        # ... other overrides
    )
    
    # Re-export with original names
    test_volumes = test_volumes_dind
    cwa_container = cwa_container_dind

Test Compatibility Layer

# Helper function for universal copy
def volume_copy(src, dest):
    """Copy file in either mode"""
    if USE_DOCKER_VOLUMES:
        # dest is VolumeHelper
        if isinstance(src, (str, Path)):
            dest.copy_to(src)
        else:
            # src is VolumeHelper
            src.copy_from(dest)
    else:
        # Standard shutil copy
        shutil.copy2(src, dest)

Test Results

Docker Volume Mode Results

USE_DOCKER_VOLUMES=true pytest tests/integration/ -v

Output:

tests/integration/test_ingest_pipeline.py::test_ingest_epub_already_target_format PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_empty_file PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_corrupted_file PASSED
tests/integration/test_ingest_pipeline.py::test_txt_to_epub_conversion PASSED
tests/integration/test_ingest_pipeline.py::test_filename_truncation_at_150_chars PASSED
tests/integration/test_ingest_pipeline.py::test_book_appears_in_metadata_db PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_multiple_files PASSED
tests/integration/test_ingest_pipeline.py::test_imported_files_backed_up PASSED
tests/integration/test_ingest_pipeline.py::test_ingest_international_filename PASSED
tests/integration/test_ingest_pipeline.py::test_mobi_to_epub_conversion PASSED
tests/integration/test_ingest_pipeline.py::test_conversion_failure_moves_to_failed_folder PASSED
tests/integration/test_ingest_pipeline.py::test_lock_released_after_processing PASSED
tests/integration/test_ingest_pipeline.py::test_directory_import_processes_all_files PASSED
tests/integration/test_ingest_pipeline.py::test_empty_folder_cleanup_after_processing PASSED
tests/integration/test_ingest_pipeline.py::test_ignored_formats_not_deleted PASSED
tests/integration/test_ingest_pipeline.py::test_processing_survives_multiple_files PASSED
tests/integration/test_ingest_pipeline.py::test_zero_byte_file_doesnt_crash_ingest PASSED
tests/integration/test_ingest_pipeline.py::test_cwa_db_tracks_import SKIPPED (requires config volume)
tests/integration/test_ingest_pipeline.py::test_user_drops_book_and_it_appears_in_library PASSED
tests/integration/test_ingest_pipeline.py::test_mixed_format_batch_import PASSED

19 passed, 1 skipped in 187.45s

100% of runnable tests passing!

Bind Mount Mode Results (for comparison)

pytest tests/integration/ -v

Output:

20 passed in 165.32s

All tests passing

Performance Comparison

Metric Bind Mount Docker Volume Difference
Container startup Variable (30-60s) Consistent (~12s) ✅ Faster (log polling)
File processing 2-5 seconds 2-5 seconds Same
Full test suite ~3 minutes ~3 minutes Same
Tests passing 20/20 19/20 1 skip (expected)

Conclusion: Docker volume mode has equivalent performance!


Limitations

Test Skipped in Volume Mode

Test: test_cwa_db_tracks_import

Reason: Requires access to /config/cwa.db which is not mounted in test containers.

Workaround: Would need to add config volume to test setup:

container.with_volume_mapping(config_vol, "/config")

Status: ⏭️ Intentionally skipped - not worth the complexity for 1 test.

Glob Pattern Simplifications

Complex multi-level glob patterns are simplified in volume mode:

# Bind mount mode (works)
files = list(Path("/calibre-library").glob("**/metadata.db"))

# Volume mode (simplified)
files = [f for f in volume.list_files() if f.endswith("metadata.db")]

Impact: Minimal - tests adjusted to work in both modes.

Performance Overhead

docker cp operations add slight overhead:

  • Copy 1 MB file: +10-20ms
  • Copy 100 MB file: +200-500ms

Impact: Negligible for tests (files are small, <10 MB).

No Direct File System Access

Can't use standard Python file operations directly:

# ❌ Doesn't work in volume mode
with open(volume / "file.txt", "r") as f:
    content = f.read()

# ✅ Use VolumeHelper methods
content = volume.read_to_temp("file.txt")
with open(content, "r") as f:
    data = f.read()

Impact: Minimal - tests use helper functions that work in both modes.


Troubleshooting

"Docker volume not found"

Cause: Volume wasn't created or was already deleted.

Fix: Check volume exists:

docker volume ls | grep cwa_test

If missing, test cleanup may have failed. Remove stale volumes:

docker volume prune -f

"Container can't access volume"

Cause: Volume mount failed.

Fix: Check container logs:

docker logs temp-cwa-test-suite

Recreate container with correct volume mounts.

"File not found in volume"

Cause: File wasn't copied or copy failed silently.

Fix: List volume contents:

files = volume.list_files()
print(files)

Check docker cp command succeeded:

docker cp file.epub volume_container:/cwa-book-ingest/
echo $?  # Should be 0

"Database is locked"

Cause: Multiple processes accessing SQLite in volume.

Fix: Use get_db_path() helper which extracts DB to temp file:

db_path = get_db_path(folder, "cwa.db")
conn = sqlite3.connect(db_path)  # Works on local file, no lock conflicts

Tests slower than expected

First run? Docker needs to create volumes and download images.

Subsequent runs should be ~3-4 minutes for integration tests.

Still slow?

  • Check Docker Desktop resources (CPU/RAM)
  • Use SSD instead of HDD
  • Close other containers
  • Check network speed (volume creation requires Docker API calls)

Environment variable not working

Check it's set:

echo $USE_DOCKER_VOLUMES  # Should show: true

Set it:

export USE_DOCKER_VOLUMES=true
pytest tests/integration/ -v

Or use one-liner:

USE_DOCKER_VOLUMES=true pytest tests/integration/ -v

CI tests failing but local tests pass

Cause: CI uses bind mount mode by default.

Fix: CI should NOT use USE_DOCKER_VOLUMES=true (runs on host, not in container).

If CI is in a container (e.g., GitHub Actions with container job):

# .github/workflows/tests.yml
- name: Run tests
  env:
    USE_DOCKER_VOLUMES: true  # Only if running in container job
  run: pytest tests/integration/ -v

Advanced Topics

Custom Volume Names

Override volume names for debugging:

@pytest.fixture
def test_volumes_dind():
    ingest_vol = VolumeHelper("my-custom-ingest-vol")
    library_vol = VolumeHelper("my-custom-library-vol")
    # ...

Then inspect volumes:

docker volume inspect my-custom-ingest-vol
docker run --rm -v my-custom-ingest-vol:/data alpine ls -la /data

Accessing Volumes Manually

Explore volume contents:

# Create temp container with volume mounted
docker run --rm -it -v cwa_test_ingest_xxx:/data alpine sh

# Inside container
cd /data
ls -la
cat book.epub
exit

Debugging File Operations

Enable debug logging in VolumeHelper:

class VolumeHelper:
    def __init__(self, volume_name, verbose=True):
        self.verbose = verbose
        # ...
    
    def copy_to(self, src, dest=None):
        if self.verbose:
            print(f"DEBUG: Copying {src} to volume {self.volume_name}")
        # ...

Preserving Volumes After Tests

For debugging, keep volumes after test failure:

@pytest.fixture
def test_volumes_dind():
    ingest_vol = VolumeHelper("cwa_test_ingest_xxx")
    library_vol = VolumeHelper("cwa_test_library_xxx")
    
    yield ingest_vol, library_vol
    
    # Only cleanup on success
    if not hasattr(pytest, 'failed'):
        ingest_vol.cleanup()
        library_vol.cleanup()

Then inspect volumes after failed tests.


Implementation Files

Core files:

  • tests/conftest.py - Main fixtures + mode detection
  • tests/conftest_volumes.py - Docker volume implementation
  • tests/integration/test_ingest_pipeline.py - Tests using both modes

Helper functions:

  • volume_copy(src, dest) - Universal copy function
  • get_db_path(folder, db_name) - Database access abstraction

Documentation:

  • DIND_MODE_COMPLETE.md - Implementation summary
  • HYBRID_DOCKER_IMPLEMENTATION.md - Technical details
  • This guide - Usage documentation

Next Steps


Questions?


Docker-in-Docker testing works perfectly! 🎉

100% of runnable tests passing with equivalent performance to bind mount mode.

Clone this wiki locally