diff --git a/.dockerignore b/.dockerignore index a3aab7a..9a0b112 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,19 @@ # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file # Ignore build and test binaries. bin/ +target/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.git/ +.github/ +*.egg-info/ +__pycache__/ +*.pyc +*.pyo +.DS_Store +.env +docker-compose*.yml +docs/ +examples/ +python/tests/ diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 736f2f6..b29342d 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -36,5 +36,8 @@ jobs: with: toolchain: stable - - name: Run tests + - name: Run Unit tests run: make test + + - name: Run E2E tests + run: make test-e2e diff --git a/Cargo.lock b/Cargo.lock index 352a029..c193e6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,9 +205,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -717,9 +717,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes 1.11.1", @@ -900,9 +900,9 @@ checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -949,9 +949,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1699,9 +1699,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1736,9 +1736,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys", @@ -1846,7 +1846,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes 1.11.1", "libc", - "mio 1.2.0", + "mio 1.2.1", "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", @@ -2128,9 +2128,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -2164,9 +2164,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2576,18 +2576,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e69de29..0000000 diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 0000000..3fe6294 --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,39 @@ +# Multi-stage build for SandD daemon - Alpine Linux (musl-based) +# Build with musl target for Alpine compatibility +FROM rust:1.85-alpine as builder + +WORKDIR /app + +# Install build dependencies for Alpine +RUN apk add --no-cache \ + musl-dev \ + pkgconfig \ + openssl-dev \ + openssl-libs-static + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY sandd/ ./sandd/ +COPY server/ ./server/ + +# Build the daemon binary in release mode +# Alpine uses musl libc, which is already the default target +RUN cargo build --package sandd --release + +# Runtime stage - Alpine for minimal size +FROM alpine:3.21 + +# Install runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + libgcc + +# Copy the binary from builder +COPY --from=builder /app/target/release/sandd /usr/local/bin/sandd + +# Set working directory +WORKDIR /workspace + +# Default command - can be overridden +ENTRYPOINT ["/usr/local/bin/sandd"] +CMD ["--help"] diff --git a/Dockerfile.debian b/Dockerfile.debian new file mode 100644 index 0000000..2328b06 --- /dev/null +++ b/Dockerfile.debian @@ -0,0 +1,38 @@ +# Multi-stage build for SandD daemon +# Use latest Rust for building +FROM rust:1.85-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY sandd/ ./sandd/ +COPY server/ ./server/ + +# Build the daemon binary in release mode +RUN cargo build --package sandd --release + +# Runtime stage - use trixie for newer glibc +FROM debian:trixie-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the binary from builder +COPY --from=builder /app/target/release/sandd /usr/local/bin/sandd + +# Set working directory +WORKDIR /workspace + +# Default command - can be overridden +ENTRYPOINT ["/usr/local/bin/sandd"] +CMD ["--help"] diff --git a/Dockerfile.rocky b/Dockerfile.rocky new file mode 100644 index 0000000..8545554 --- /dev/null +++ b/Dockerfile.rocky @@ -0,0 +1,38 @@ +# Multi-stage build for SandD daemon - Rocky Linux (RHEL-based) +# Use Rust builder stage +FROM rust:1.85-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY sandd/ ./sandd/ +COPY server/ ./server/ + +# Build the daemon binary in release mode +RUN cargo build --package sandd --release + +# Runtime stage - Rocky Linux 9 +FROM rockylinux:9-minimal + +# Install runtime dependencies +RUN microdnf install -y \ + ca-certificates \ + openssl-libs \ + && microdnf clean all + +# Copy the binary from builder +COPY --from=builder /app/target/release/sandd /usr/local/bin/sandd + +# Set working directory +WORKDIR /workspace + +# Default command - can be overridden +ENTRYPOINT ["/usr/local/bin/sandd"] +CMD ["--help"] diff --git a/Makefile b/Makefile index d4ef509..3dddf03 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ RUFF := .venv/bin/ruff PYTEST := .venv/bin/pytest MATURIN := .venv/bin/maturin -.PHONY: help build install dev test clean daemon-build daemon-release +.PHONY: help build install dev test clean daemon-build daemon-release test-e2e docker-build docker-down help: @echo "SandD - Sandbox Daemon - Build Commands" @@ -10,9 +10,12 @@ help: @echo " make build - Build Python package (debug mode)" @echo " make install - Install Python package locally" @echo " make dev - Install in development mode with hot reload" - @echo " make test - Run tests" + @echo " make test - Run unit and integration tests" + @echo " make test-e2e - Run end-to-end tests with Docker" @echo " make daemon-build - Build daemon binary (debug)" @echo " make daemon-release - Build daemon binary (release)" + @echo " make docker-build - Build Docker image for daemon" + @echo " make docker-down - Stop and remove Docker containers" @echo " make clean - Clean build artifacts" build: $(MATURIN) @@ -48,6 +51,25 @@ clean: rm -rf python/sandd.egg-info/ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +test-e2e: $(PYTEST) dev + @echo "Building Docker images..." + docker compose -f docker-compose.e2e.yml build + @echo "" + @echo "Running E2E tests with Docker..." + $(PYTEST) python/tests/test_e2e.py -v -s + @echo "" + @echo "Cleaning up containers..." + docker compose -f docker-compose.e2e.yml down + +docker-build: + docker compose -f docker-compose.e2e.yml build + +docker-down: + docker compose -f docker-compose.e2e.yml down + +test-all: test test-e2e + @echo "All tests completed successfully" + .PHONY: lint lint: $(RUFF) $(RUFF) check . diff --git a/README.md b/README.md index 49339fe..0f5c6bd 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # SandD -**Sandbox Daemon for Secure Remote Command Execution** +**A Lightweight Sandbox Daemon for Secure Agent Execution in Isolated Environments.** -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/rust-1.70+-orange.svg)](https://www.rust-lang.org/) [![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) Rust-powered WebSocket server with Python API for secure command execution in isolated environments. @@ -114,6 +114,10 @@ print(f"Output: {result.stdout}") # ... repeat for n+ machines ``` +## Examples + +See the [examples/](./examples) directory for common use cases. + ## Development See [DEVELOP.md](./docs/DEVELOP.md) for the complete developer guide including build commands, testing, and troubleshooting. diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..f7d64ef --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,141 @@ +version: '3.8' + +services: + # Debian-based daemons + daemon-debian-1: + build: + context: . + dockerfile: Dockerfile.debian + container_name: sandd-daemon-debian-1 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-debian-1 + --label env=test + --label distro=debian + --label region=us-east + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + + daemon-debian-2: + build: + context: . + dockerfile: Dockerfile.debian + container_name: sandd-daemon-debian-2 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-debian-2 + --label env=test + --label distro=debian + --label region=us-west + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + + # Alpine-based daemons + daemon-alpine-1: + build: + context: . + dockerfile: Dockerfile.alpine + container_name: sandd-daemon-alpine-1 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-alpine-1 + --label env=test + --label distro=alpine + --label region=eu-west + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + + daemon-alpine-2: + build: + context: . + dockerfile: Dockerfile.alpine + container_name: sandd-daemon-alpine-2 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-alpine-2 + --label env=prod + --label distro=alpine + --label region=ap-south + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + + # Rocky Linux-based daemons + daemon-rocky-1: + build: + context: . + dockerfile: Dockerfile.rocky + container_name: sandd-daemon-rocky-1 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-rocky-1 + --label env=prod + --label distro=rocky + --label region=eu-central + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + + daemon-rocky-2: + build: + context: . + dockerfile: Dockerfile.rocky + container_name: sandd-daemon-rocky-2 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-rocky-2 + --label env=test + --label distro=rocky + --label region=ap-northeast + networks: + - sandd-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD", "pgrep", "-f", "sandd"] + interval: 5s + timeout: 3s + retries: 3 + +networks: + sandd-network: + driver: bridge diff --git a/examples/install_htop.py b/examples/install_htop.py new file mode 100755 index 0000000..e1162bb --- /dev/null +++ b/examples/install_htop.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Example: Installing htop on daemons + +This example shows how to check if htop is available and install it if needed. +htop is a small, interactive process viewer - perfect for demonstrating +package installation across different distributions. + +Usage: + python examples/install_htop.py +""" + +from sandd import Server +import sys +import time + + +def check_htop_available(server, daemon_id): + """Check if htop is available on a daemon""" + result = server.execute_command(daemon_id, "which htop", timeout=5) + return result.success + + +def install_htop(server, daemon_id): + """Install htop on a daemon using the system package manager""" + print(f"Installing htop on {daemon_id}...") + + # Detect platform (macOS vs Linux) + platform_result = server.execute_command(daemon_id, "uname -s", timeout=5) + if not platform_result.success: + print("❌ Could not detect platform") + return False + + platform = platform_result.stdout.strip() + + # Handle macOS + if platform == "Darwin": + print(" Detected macOS, using Homebrew...") + cmd = "brew install htop" + else: + # Linux - detect distribution + distro_result = server.execute_command( + daemon_id, + "cat /etc/os-release 2>/dev/null || echo 'unknown'", + timeout=5 + ) + + if not distro_result.success: + print("❌ Could not detect distribution") + return False + + distro = distro_result.stdout.lower() + + # Determine install command based on distribution + if "alpine" in distro: + cmd = "apk update && apk add htop" + elif "ubuntu" in distro or "debian" in distro: + cmd = "apt-get update && apt-get install -y htop" + elif "rocky" in distro or "rhel" in distro or "centos" in distro: + cmd = "microdnf install -y htop || dnf install -y htop || yum install -y htop" + elif "fedora" in distro: + cmd = "dnf install -y htop" + else: + print("❌ Unknown Linux distribution") + return False + + # Execute installation + result = server.execute_command(daemon_id, cmd, timeout=120) + + if result.success: + print("✓ htop installed successfully") + return True + else: + print(f"❌ Failed to install htop: {result.stderr}") + return False + + +def main(): + print("htop Installation Example") + print("=" * 50) + + # Connect to server + server = Server("127.0.0.1", 8765) + print(f"✓ Server started on {server.address}\n") + + # Wait for at least one daemon + print("Waiting for daemons to connect...") + daemons = server.list_daemons() + while not daemons: + time.sleep(1) + daemons = server.list_daemons() + + daemon_id = daemons[0] + print(f"✓ Found daemon: {daemon_id}\n") + + # Check if htop is available + print("Checking if htop is available...") + if check_htop_available(server, daemon_id): + print("✓ htop is already installed") + + # Get htop version + result = server.execute_command(daemon_id, "htop --version", timeout=5) + if result.success: + # htop version is usually first line + version_line = result.stdout.split('\n')[0] + print(f" {version_line}") + else: + print("✗ htop is not installed") + print() + + # Install htop + if install_htop(server, daemon_id): + # Verify installation + result = server.execute_command(daemon_id, "htop --version", timeout=5) + if result.success: + version_line = result.stdout.split('\n')[0] + print(f" {version_line}") + else: + print("Failed to install htop") + sys.exit(1) + + print() + + # Demonstrate htop is working by showing process info + print("Testing htop functionality...") + print("-" * 50) + + # Since htop is interactive, we can't run it directly + # But we can verify it works by checking its help and showing system info + test_commands = [ + ("htop --help | head -5", "Show htop help"), + ("ps aux | head -10", "Show running processes (what htop displays)"), + ("uptime", "Show system uptime"), + ] + + for cmd, description in test_commands: + result = server.execute_command(daemon_id, cmd, timeout=10) + if result.success: + print(f"✓ {description}") + # Show first few lines of output + output_lines = result.stdout.strip().split('\n')[:3] + for line in output_lines: + print(f" {line}") + if len(result.stdout.strip().split('\n')) > 3: + print(" ...") + print() + else: + print(f"✗ {description}: {result.stderr}\n") + + print("=" * 50) + print("Example complete!") + print() + print("Note: htop is an interactive tool. To use it interactively,") + print(" use server.start_shell() instead of execute_command().") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nInterrupted by user") + sys.exit(0) + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py new file mode 100644 index 0000000..d98c20d --- /dev/null +++ b/python/tests/test_e2e.py @@ -0,0 +1,234 @@ +"""End-to-end tests with Docker containers + +Run with: make test-e2e +""" +import pytest +import time +import subprocess +from sandd import Server + + +@pytest.fixture(scope="module") +def docker_daemons(): + """Start Docker containers with daemons""" + compose_file = "docker-compose.e2e.yml" + + # Build and start containers + subprocess.run( + ["docker", "compose", "-f", compose_file, "build"], + check=True, + capture_output=True + ) + + subprocess.run( + ["docker", "compose", "-f", compose_file, "up", "-d"], + check=True, + capture_output=True + ) + + yield + + # Cleanup + subprocess.run( + ["docker", "compose", "-f", compose_file, "down"], + capture_output=True + ) + + +@pytest.fixture(scope="module") +def server(docker_daemons): + """Create server instance for E2E tests""" + srv = Server(host="0.0.0.0", port=8765) + + # Wait for all daemons to connect (2 debian + 2 alpine + 2 rocky) + daemon_ids = [ + "daemon-debian-1", "daemon-debian-2", + "daemon-alpine-1", "daemon-alpine-2", + "daemon-rocky-1", "daemon-rocky-2" + ] + for daemon_id in daemon_ids: + connected = srv.wait_for_daemon(daemon_id, timeout=15.0) + if not connected: + pytest.fail(f"Daemon {daemon_id} failed to connect") + + yield srv + + +class TestE2EBasicOperations: + """Basic E2E operations across Docker containers""" + + def test_all_daemons_connected(self, server): + """Verify all 6 daemons connected (2 debian + 2 alpine + 2 rocky)""" + daemons = server.list_daemons() + expected = [ + "daemon-debian-1", "daemon-debian-2", + "daemon-alpine-1", "daemon-alpine-2", + "daemon-rocky-1", "daemon-rocky-2" + ] + for daemon_id in expected: + assert daemon_id in daemons + assert server.daemon_count() >= 6 + + def test_execute_on_each_daemon(self, server): + """Execute commands on each daemon across all distributions""" + daemon_ids = [ + "daemon-debian-1", "daemon-debian-2", + "daemon-alpine-1", "daemon-alpine-2", + "daemon-rocky-1", "daemon-rocky-2" + ] + for daemon_id in daemon_ids: + result = server.execute_command( + daemon_id, + "echo 'Hello from container'", + timeout=5 + ) + assert result.success + assert "Hello from container" in result.stdout + + def test_concurrent_execution(self, server): + """Execute commands concurrently on multiple daemons""" + import concurrent.futures + + daemon_ids = [ + "daemon-debian-1", "daemon-alpine-1", "daemon-rocky-1" + ] + + def run_cmd(daemon_id): + return server.execute_command( + daemon_id, + f"echo 'Response from {daemon_id}'", + timeout=5 + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(run_cmd, did) for did in daemon_ids] + results = [f.result() for f in futures] + + assert all(r.success for r in results) + assert all("Response from" in r.stdout for r in results) + + +class TestE2ELabels: + """Test label-based filtering in E2E""" + + def test_filter_by_env_label(self, server): + """Filter daemons by env label""" + test_daemons = server.list_daemons(label_key="env", label_value="test") + assert "daemon-debian-1" in test_daemons + assert "daemon-debian-2" in test_daemons + assert "daemon-alpine-1" in test_daemons + assert "daemon-rocky-2" in test_daemons + + prod_daemons = server.list_daemons(label_key="env", label_value="prod") + assert "daemon-alpine-2" in prod_daemons + assert "daemon-rocky-1" in prod_daemons + + def test_filter_by_distro_label(self, server): + """Filter daemons by distribution""" + debian_daemons = server.list_daemons(label_key="distro", label_value="debian") + assert "daemon-debian-1" in debian_daemons + assert "daemon-debian-2" in debian_daemons + assert len(debian_daemons) >= 2 + + alpine_daemons = server.list_daemons(label_key="distro", label_value="alpine") + assert "daemon-alpine-1" in alpine_daemons + assert "daemon-alpine-2" in alpine_daemons + + rocky_daemons = server.list_daemons(label_key="distro", label_value="rocky") + assert "daemon-rocky-1" in rocky_daemons + assert "daemon-rocky-2" in rocky_daemons + + +class TestE2EResilience: + """Test system resilience""" + + def test_daemon_restart(self, server): + """Test daemon reconnection after container restart""" + # Execute command before restart + result = server.execute_command("daemon-debian-1", "echo 'before'", timeout=5) + assert result.success + + # Restart container + subprocess.run( + ["docker", "restart", "sandd-daemon-debian-1"], + check=True, + capture_output=True + ) + + # Wait for reconnection + time.sleep(5) + reconnected = server.wait_for_daemon("daemon-debian-1", timeout=15.0) + assert reconnected + + # Execute command after restart + result = server.execute_command("daemon-debian-1", "echo 'after'", timeout=5) + assert result.success + assert "after" in result.stdout + + +class TestE2EStats: + """Test statistics with Docker daemons""" + + def test_stats_reflect_containers(self, server): + """Verify stats show all container daemons""" + stats = server.get_stats() + assert stats.total_daemons >= 6 + assert "linux" in [p.lower() for p in stats.by_platform.keys()] + + +class TestE2EDistributionSpecific: + """Test distribution-specific commands""" + + def test_package_manager_debian(self, server): + """Test apt package manager on Debian daemons""" + result = server.execute_command( + "daemon-debian-1", + "apt-get update && apt-get install -y curl", + timeout=60 + ) + assert result.success + + result = server.execute_command("daemon-debian-1", "curl --version", timeout=5) + assert result.success + assert "curl" in result.stdout + + def test_package_manager_alpine(self, server): + """Test apk package manager on Alpine daemons""" + result = server.execute_command( + "daemon-alpine-1", + "apk update && apk add curl", + timeout=60 + ) + assert result.success + + result = server.execute_command("daemon-alpine-1", "curl --version", timeout=5) + assert result.success + assert "curl" in result.stdout + + def test_package_manager_rocky(self, server): + """Test dnf package manager on Rocky daemons""" + result = server.execute_command( + "daemon-rocky-1", + "microdnf install -y curl", + timeout=60 + ) + assert result.success + + result = server.execute_command("daemon-rocky-1", "curl --version", timeout=5) + assert result.success + assert "curl" in result.stdout + + def test_all_distros_run_same_command(self, server): + """Verify all distributions can run common commands""" + daemon_ids = [ + "daemon-debian-1", + "daemon-alpine-1", + "daemon-rocky-1" + ] + for daemon_id in daemon_ids: + result = server.execute_command(daemon_id, "uname -s", timeout=5) + assert result.success + assert result.stdout.strip() == "Linux" + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"])