From ca8d9f0d39ba4c3e61e397bef50d5aa965f9fd18 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Mon, 1 Jun 2026 00:31:44 +0800 Subject: [PATCH 1/8] add example Signed-off-by: kerthcet --- README.md | 4 ++ examples/install_git.py | 148 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 examples/install_git.py diff --git a/README.md b/README.md index 49339fe..6bd227d 100644 --- a/README.md +++ b/README.md @@ -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/examples/install_git.py b/examples/install_git.py new file mode 100644 index 0000000..d6f0184 --- /dev/null +++ b/examples/install_git.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Example: Installing git on daemons + +This example shows how to check if git is available and install it if needed. + +Usage: + python examples/install_git.py +""" + +from sandd import Server +import sys +import time + + +def check_git_available(server, daemon_id): + """Check if git is available on a daemon""" + result = server.execute_command(daemon_id, "which git", timeout=5) + return result.success + + +def install_git(server, daemon_id): + """Install git on a daemon using the system package manager""" + print(f"Installing git on {daemon_id}...") + + # Detect platform + 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().lower() + + # Determine install command + if "linux" in platform: + # Check if it's Debian/Ubuntu or RHEL/CentOS + distro_result = server.execute_command( + daemon_id, + "cat /etc/os-release 2>/dev/null || echo 'unknown'", + timeout=5 + ) + distro = distro_result.stdout.lower() + + if "ubuntu" in distro or "debian" in distro: + cmd = "sudo apt-get update && sudo apt-get install -y git" + elif "centos" in distro or "rhel" in distro or "fedora" in distro: + cmd = "sudo yum install -y git" + else: + # Default to apt for unknown Linux + cmd = "sudo apt-get update && sudo apt-get install -y git" + + elif "darwin" in platform: + cmd = "brew install git" + else: + print(f"❌ Unsupported platform: {platform}") + return False + + # Execute installation + result = server.execute_command(daemon_id, cmd, timeout=300) + + if result.success: + print("✓ git installed successfully") + return True + else: + print(f"❌ Failed to install git: {result.stderr}") + return False + + +def main(): + print("Git 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 git is available + print("Checking if git is available...") + if check_git_available(server, daemon_id): + print("✓ git is already installed") + + # Get git version + result = server.execute_command(daemon_id, "git --version", timeout=5) + print(f" Version: {result.stdout.strip()}") + else: + print("✗ git is not installed") + print() + + # Install git + if install_git(server, daemon_id): + # Verify installation + result = server.execute_command(daemon_id, "git --version", timeout=5) + if result.success: + print(f" Version: {result.stdout.strip()}") + else: + print("Failed to install git") + sys.exit(1) + + print() + + # Test git functionality + print("Testing git functionality...") + print("-" * 50) + + # Create a test repo + test_commands = [ + ("mkdir -p /tmp/test-repo && cd /tmp/test-repo", "Create test directory"), + ("git init", "Initialize git repo"), + ("git config user.name 'Test User'", "Configure user"), + ("git config user.email 'test@example.com'", "Configure email"), + ("echo 'Hello from SandD' > README.md", "Create file"), + ("git add README.md", "Stage file"), + ("git commit -m 'Initial commit'", "Create commit"), + ("git log --oneline", "Show commit"), + ] + + for cmd, description in test_commands: + result = server.execute_command(daemon_id, cmd, timeout=10) + if result.success: + print(f"✓ {description}") + if "git log" in cmd: + print(f" {result.stdout.strip()}") + else: + print(f"✗ {description}: {result.stderr}") + + print() + print("=" * 50) + print("Example complete!") + + +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) From 8decd49c0a008a08e9280bd12a1cb659f2c10484 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 06:41:06 +0800 Subject: [PATCH 2/8] add e2e tests Signed-off-by: kerthcet --- .dockerignore | 16 +++ .github/workflows/rust-ci.yaml | 5 +- Cargo.lock | 42 +++---- Dockerfile | 0 Dockerfile.e2e | 38 ++++++ Makefile | 26 +++- README.md | 4 +- docker-compose.e2e.yml | 70 +++++++++++ docs/E2E_TESTING.md | 213 +++++++++++++++++++++++++++++++++ python/tests/test_e2e.py | 191 +++++++++++++++++++++++++++++ 10 files changed, 579 insertions(+), 26 deletions(-) delete mode 100644 Dockerfile create mode 100644 Dockerfile.e2e create mode 100644 docker-compose.e2e.yml create mode 100644 docs/E2E_TESTING.md create mode 100644 python/tests/test_e2e.py 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..0eab18f 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 e2e-test 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.e2e b/Dockerfile.e2e new file mode 100644 index 0000000..2328b06 --- /dev/null +++ b/Dockerfile.e2e @@ -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/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 6bd227d..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. diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..3164332 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,70 @@ +version: '3.8' + +services: + # The daemon running in a container + daemon1: + build: + context: . + dockerfile: Dockerfile.e2e + container_name: sandd-daemon-1 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-1 + --label env=test + --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 + + daemon2: + build: + context: . + dockerfile: Dockerfile.e2e + container_name: sandd-daemon-2 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-2 + --label env=test + --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 + + daemon3: + build: + context: . + dockerfile: Dockerfile.e2e + container_name: sandd-daemon-3 + command: > + --server-url ws://host.docker.internal:8765/ws + --daemon-id daemon-3 + --label env=prod + --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 + +networks: + sandd-network: + driver: bridge diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md new file mode 100644 index 0000000..c807662 --- /dev/null +++ b/docs/E2E_TESTING.md @@ -0,0 +1,213 @@ +# End-to-End Testing with Docker + +This guide explains how to run E2E tests for SandD using Docker containers. + +## Overview + +The E2E test suite runs multiple daemon instances in Docker containers that connect to a Python agent server running on the host machine. This simulates a real distributed environment. + +## Architecture + +``` +┌─────────────────────────────┐ +│ Host Machine │ +│ ┌────────────────────────┐ │ +│ │ Python Test Suite │ │ +│ │ (pytest) │ │ +│ │ │ │ +│ │ Server: 0.0.0.0:8765 │ │ +│ └────────┬───────────────┘ │ +│ │ │ +└───────────┼─────────────────┘ + │ WebSocket + ┌──────┼──────┬──────────┐ + │ │ │ │ +┌────▼───┐ ┌▼─────▼┐ ┌──────▼──┐ +│Daemon-1│ │Daemon-2│ │Daemon-3 │ +│Container│ │Container│ │Container│ +│env=test│ │env=test│ │env=prod │ +│us-east │ │us-west │ │eu-west │ +└────────┘ └────────┘ └─────────┘ +``` + +## Prerequisites + +1. Docker and Docker Compose installed +2. Python development environment set up +3. Project built with `make dev` + +## Running E2E Tests + +### Quick Start + +```bash +# Run all E2E tests +make test-e2e +``` + +This command will: +1. Build Docker images for the daemon +2. Start 3 daemon containers +3. Run the E2E test suite +4. Clean up containers + +### Manual Steps + +```bash +# Build Docker images +make docker-build + +# Start containers +docker compose -f docker-compose.e2e.yml up -d + +# Run tests +.venv/bin/pytest python/tests/test_e2e.py -v -s + +# Stop containers +make docker-down +``` + +## Test Coverage + +The E2E test suite covers: + +### Basic Operations +- **Connection**: All 3 daemons connect successfully +- **Command Execution**: Execute commands on each daemon +- **Concurrent Execution**: Run commands simultaneously on multiple daemons + +### Label-Based Filtering +- Filter daemons by `env` label (test/prod) +- Filter daemons by `region` label (us-east, us-west, eu-west) + +### File Transfer +- Upload files to daemon containers +- Download files from daemon containers +- Cross-container file operations + +### Resilience +- Daemon reconnection after container restart +- Connection stability under load + +### Statistics +- Server stats reflect all connected daemons +- Platform detection for containerized daemons + +## Test Fixtures + +### `docker_daemons` (module scope) +Starts and manages Docker containers for the test session. + +### `server` (module scope) +Creates a Server instance and waits for all daemons to connect. + +## Configuration + +### Docker Compose + +The `docker-compose.e2e.yml` defines 3 daemon containers: + +| Container | ID | Labels | +|-----------|-----|--------| +| sandd-daemon-1 | daemon-1 | env=test, region=us-east | +| sandd-daemon-2 | daemon-2 | env=test, region=us-west | +| sandd-daemon-3 | daemon-3 | env=prod, region=eu-west | + +### Network Configuration + +Daemons use `host.docker.internal` to connect to the host machine's server. This works on: +- Docker Desktop (Mac/Windows) +- Linux with `extra_hosts` configuration + +## Troubleshooting + +### Daemons not connecting + +```bash +# Check container logs +docker logs sandd-daemon-1 + +# Check if containers are running +docker ps | grep sandd + +# Test connectivity from container +docker exec sandd-daemon-1 ping host.docker.internal +``` + +### Port conflicts + +If port 8765 is in use: +1. Stop other services using that port +2. Or modify the port in `docker-compose.e2e.yml` and `test_e2e.py` + +### Build failures + +```bash +# Clean and rebuild +make clean +docker compose -f docker-compose.e2e.yml build --no-cache +``` + +### Tests hanging + +```bash +# Force stop containers +docker compose -f docker-compose.e2e.yml down -v + +# Check for zombie processes +ps aux | grep sandd +``` + +## CI/CD Integration + +For GitHub Actions or other CI systems: + +```yaml +- name: Run E2E Tests + run: | + make dev + make test-e2e +``` + +## Performance + +- **Build time**: ~2-3 minutes (first build) +- **Test duration**: ~30-60 seconds +- **Cleanup time**: ~5 seconds + +## Advanced Usage + +### Running specific test classes + +```bash +.venv/bin/pytest python/tests/test_e2e.py::TestE2ELabels -v +``` + +### Running with more daemons + +Modify `docker-compose.e2e.yml` to add more daemon services, then update the test fixtures accordingly. + +### Debug mode + +```bash +# Keep containers running after tests +docker compose -f docker-compose.e2e.yml up -d +.venv/bin/pytest python/tests/test_e2e.py -v -s --pdb +# Containers stay up for debugging +``` + +## Security Considerations + +E2E tests use insecure WebSocket (`ws://`) for simplicity. Production deployments should use: +- WSS (WebSocket Secure) with TLS +- Authentication tokens +- Network policies +- Resource limits + +## Future Enhancements + +- [ ] Add stress tests with 50+ containers +- [ ] Test network failures and reconnection +- [ ] Test TLS/WSS connections +- [ ] Add performance benchmarks +- [ ] Test resource limits enforcement diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py new file mode 100644 index 0000000..c1e2cc7 --- /dev/null +++ b/python/tests/test_e2e.py @@ -0,0 +1,191 @@ +"""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 + for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + connected = srv.wait_for_daemon(daemon_id, timeout=10.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 3 daemons connected""" + daemons = server.list_daemons() + assert "daemon-1" in daemons + assert "daemon-2" in daemons + assert "daemon-3" in daemons + assert server.daemon_count() == 3 + + def test_execute_on_each_daemon(self, server): + """Execute commands on each daemon""" + for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + 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 + + 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, f"daemon-{i}") + for i in range(1, 4) + ] + 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-1" in test_daemons + assert "daemon-2" in test_daemons + assert "daemon-3" not in test_daemons + + prod_daemons = server.list_daemons(label_key="env", label_value="prod") + assert "daemon-3" in prod_daemons + assert "daemon-1" not in prod_daemons + + def test_filter_by_region_label(self, server): + """Filter daemons by region label""" + us_east = server.list_daemons(label_key="region", label_value="us-east") + assert "daemon-1" in us_east + + eu_west = server.list_daemons(label_key="region", label_value="eu-west") + assert "daemon-3" in eu_west + + +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-1", "echo 'before'", timeout=5) + assert result.success + + # Restart container + subprocess.run( + ["docker", "restart", "sandd-daemon-1"], + check=True, + capture_output=True + ) + + # Wait for reconnection + time.sleep(5) + reconnected = server.wait_for_daemon("daemon-1", timeout=15.0) + assert reconnected + + # Execute command after restart + result = server.execute_command("daemon-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 == 3 + assert "linux" in [p.lower() for p in stats.by_platform.keys()] + +class TestE2ECommandExecution: + """Test command execution across Docker daemons""" + + def test_command_output(self, server): + """Verify command output from daemons""" + for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + result = server.execute_command( + daemon_id, + "uname -s", + timeout=5 + ) + assert result.success + assert result.stdout.strip() == "Linux" + + + def test_execute_install_command(self, server): + """Test executing an installation command on daemons""" + for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + result = server.execute_command( + daemon_id, + "apt-get update && apt-get install -y curl", + timeout=30 + ) + + assert result.success, f"Command failed on {daemon_id}: {result.stderr}" + + for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + result = server.execute_command( + daemon_id, + "curl --version", + timeout=5 + ) + assert result.success, f"Command failed on {daemon_id}: {result.stderr}" + assert "curl" in result.stdout + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) From 733cc64921057caa103ebe17e874fd9e0f475721 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 06:53:53 +0800 Subject: [PATCH 3/8] fix ci Signed-off-by: kerthcet --- .github/workflows/rust-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 0eab18f..b29342d 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -40,4 +40,4 @@ jobs: run: make test - name: Run E2E tests - run: make e2e-test + run: make test-e2e From 9e7daddbccd4f09b96ad97a96b9055ea44de5263 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 07:02:57 +0800 Subject: [PATCH 4/8] add distros support Signed-off-by: kerthcet --- Dockerfile.alpine | 39 +++++ Dockerfile.e2e => Dockerfile.debian | 0 Dockerfile.rocky | 38 +++++ docker-compose.e2e.yml | 99 +++++++++++-- docs/E2E_TESTING.md | 213 ---------------------------- python/tests/test_e2e.py | 161 +++++++++++++-------- 6 files changed, 264 insertions(+), 286 deletions(-) create mode 100644 Dockerfile.alpine rename Dockerfile.e2e => Dockerfile.debian (100%) create mode 100644 Dockerfile.rocky delete mode 100644 docs/E2E_TESTING.md 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.e2e b/Dockerfile.debian similarity index 100% rename from Dockerfile.e2e rename to Dockerfile.debian 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/docker-compose.e2e.yml b/docker-compose.e2e.yml index 3164332..f7d64ef 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -1,16 +1,17 @@ version: '3.8' services: - # The daemon running in a container - daemon1: + # Debian-based daemons + daemon-debian-1: build: context: . - dockerfile: Dockerfile.e2e - container_name: sandd-daemon-1 + dockerfile: Dockerfile.debian + container_name: sandd-daemon-debian-1 command: > --server-url ws://host.docker.internal:8765/ws - --daemon-id daemon-1 + --daemon-id daemon-debian-1 --label env=test + --label distro=debian --label region=us-east networks: - sandd-network @@ -23,15 +24,16 @@ services: timeout: 3s retries: 3 - daemon2: + daemon-debian-2: build: context: . - dockerfile: Dockerfile.e2e - container_name: sandd-daemon-2 + dockerfile: Dockerfile.debian + container_name: sandd-daemon-debian-2 command: > --server-url ws://host.docker.internal:8765/ws - --daemon-id daemon-2 + --daemon-id daemon-debian-2 --label env=test + --label distro=debian --label region=us-west networks: - sandd-network @@ -44,15 +46,17 @@ services: timeout: 3s retries: 3 - daemon3: + # Alpine-based daemons + daemon-alpine-1: build: context: . - dockerfile: Dockerfile.e2e - container_name: sandd-daemon-3 + dockerfile: Dockerfile.alpine + container_name: sandd-daemon-alpine-1 command: > --server-url ws://host.docker.internal:8765/ws - --daemon-id daemon-3 - --label env=prod + --daemon-id daemon-alpine-1 + --label env=test + --label distro=alpine --label region=eu-west networks: - sandd-network @@ -65,6 +69,73 @@ services: 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/docs/E2E_TESTING.md b/docs/E2E_TESTING.md deleted file mode 100644 index c807662..0000000 --- a/docs/E2E_TESTING.md +++ /dev/null @@ -1,213 +0,0 @@ -# End-to-End Testing with Docker - -This guide explains how to run E2E tests for SandD using Docker containers. - -## Overview - -The E2E test suite runs multiple daemon instances in Docker containers that connect to a Python agent server running on the host machine. This simulates a real distributed environment. - -## Architecture - -``` -┌─────────────────────────────┐ -│ Host Machine │ -│ ┌────────────────────────┐ │ -│ │ Python Test Suite │ │ -│ │ (pytest) │ │ -│ │ │ │ -│ │ Server: 0.0.0.0:8765 │ │ -│ └────────┬───────────────┘ │ -│ │ │ -└───────────┼─────────────────┘ - │ WebSocket - ┌──────┼──────┬──────────┐ - │ │ │ │ -┌────▼───┐ ┌▼─────▼┐ ┌──────▼──┐ -│Daemon-1│ │Daemon-2│ │Daemon-3 │ -│Container│ │Container│ │Container│ -│env=test│ │env=test│ │env=prod │ -│us-east │ │us-west │ │eu-west │ -└────────┘ └────────┘ └─────────┘ -``` - -## Prerequisites - -1. Docker and Docker Compose installed -2. Python development environment set up -3. Project built with `make dev` - -## Running E2E Tests - -### Quick Start - -```bash -# Run all E2E tests -make test-e2e -``` - -This command will: -1. Build Docker images for the daemon -2. Start 3 daemon containers -3. Run the E2E test suite -4. Clean up containers - -### Manual Steps - -```bash -# Build Docker images -make docker-build - -# Start containers -docker compose -f docker-compose.e2e.yml up -d - -# Run tests -.venv/bin/pytest python/tests/test_e2e.py -v -s - -# Stop containers -make docker-down -``` - -## Test Coverage - -The E2E test suite covers: - -### Basic Operations -- **Connection**: All 3 daemons connect successfully -- **Command Execution**: Execute commands on each daemon -- **Concurrent Execution**: Run commands simultaneously on multiple daemons - -### Label-Based Filtering -- Filter daemons by `env` label (test/prod) -- Filter daemons by `region` label (us-east, us-west, eu-west) - -### File Transfer -- Upload files to daemon containers -- Download files from daemon containers -- Cross-container file operations - -### Resilience -- Daemon reconnection after container restart -- Connection stability under load - -### Statistics -- Server stats reflect all connected daemons -- Platform detection for containerized daemons - -## Test Fixtures - -### `docker_daemons` (module scope) -Starts and manages Docker containers for the test session. - -### `server` (module scope) -Creates a Server instance and waits for all daemons to connect. - -## Configuration - -### Docker Compose - -The `docker-compose.e2e.yml` defines 3 daemon containers: - -| Container | ID | Labels | -|-----------|-----|--------| -| sandd-daemon-1 | daemon-1 | env=test, region=us-east | -| sandd-daemon-2 | daemon-2 | env=test, region=us-west | -| sandd-daemon-3 | daemon-3 | env=prod, region=eu-west | - -### Network Configuration - -Daemons use `host.docker.internal` to connect to the host machine's server. This works on: -- Docker Desktop (Mac/Windows) -- Linux with `extra_hosts` configuration - -## Troubleshooting - -### Daemons not connecting - -```bash -# Check container logs -docker logs sandd-daemon-1 - -# Check if containers are running -docker ps | grep sandd - -# Test connectivity from container -docker exec sandd-daemon-1 ping host.docker.internal -``` - -### Port conflicts - -If port 8765 is in use: -1. Stop other services using that port -2. Or modify the port in `docker-compose.e2e.yml` and `test_e2e.py` - -### Build failures - -```bash -# Clean and rebuild -make clean -docker compose -f docker-compose.e2e.yml build --no-cache -``` - -### Tests hanging - -```bash -# Force stop containers -docker compose -f docker-compose.e2e.yml down -v - -# Check for zombie processes -ps aux | grep sandd -``` - -## CI/CD Integration - -For GitHub Actions or other CI systems: - -```yaml -- name: Run E2E Tests - run: | - make dev - make test-e2e -``` - -## Performance - -- **Build time**: ~2-3 minutes (first build) -- **Test duration**: ~30-60 seconds -- **Cleanup time**: ~5 seconds - -## Advanced Usage - -### Running specific test classes - -```bash -.venv/bin/pytest python/tests/test_e2e.py::TestE2ELabels -v -``` - -### Running with more daemons - -Modify `docker-compose.e2e.yml` to add more daemon services, then update the test fixtures accordingly. - -### Debug mode - -```bash -# Keep containers running after tests -docker compose -f docker-compose.e2e.yml up -d -.venv/bin/pytest python/tests/test_e2e.py -v -s --pdb -# Containers stay up for debugging -``` - -## Security Considerations - -E2E tests use insecure WebSocket (`ws://`) for simplicity. Production deployments should use: -- WSS (WebSocket Secure) with TLS -- Authentication tokens -- Network policies -- Resource limits - -## Future Enhancements - -- [ ] Add stress tests with 50+ containers -- [ ] Test network failures and reconnection -- [ ] Test TLS/WSS connections -- [ ] Add performance benchmarks -- [ ] Test resource limits enforcement diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py index c1e2cc7..d98c20d 100644 --- a/python/tests/test_e2e.py +++ b/python/tests/test_e2e.py @@ -40,9 +40,14 @@ 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 - for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: - connected = srv.wait_for_daemon(daemon_id, timeout=10.0) + # 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") @@ -53,16 +58,25 @@ class TestE2EBasicOperations: """Basic E2E operations across Docker containers""" def test_all_daemons_connected(self, server): - """Verify all 3 daemons connected""" + """Verify all 6 daemons connected (2 debian + 2 alpine + 2 rocky)""" daemons = server.list_daemons() - assert "daemon-1" in daemons - assert "daemon-2" in daemons - assert "daemon-3" in daemons - assert server.daemon_count() == 3 + 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""" - for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: + """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'", @@ -75,6 +89,10 @@ 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, @@ -83,10 +101,7 @@ def run_cmd(daemon_id): ) with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: - futures = [ - executor.submit(run_cmd, f"daemon-{i}") - for i in range(1, 4) - ] + 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) @@ -99,21 +114,29 @@ class TestE2ELabels: 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-1" in test_daemons - assert "daemon-2" in test_daemons - assert "daemon-3" not in test_daemons + 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-3" in prod_daemons - assert "daemon-1" not in prod_daemons + 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 - def test_filter_by_region_label(self, server): - """Filter daemons by region label""" - us_east = server.list_daemons(label_key="region", label_value="us-east") - assert "daemon-1" in us_east + 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 - eu_west = server.list_daemons(label_key="region", label_value="eu-west") - assert "daemon-3" in eu_west + 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: @@ -122,23 +145,23 @@ class TestE2EResilience: def test_daemon_restart(self, server): """Test daemon reconnection after container restart""" # Execute command before restart - result = server.execute_command("daemon-1", "echo 'before'", timeout=5) + result = server.execute_command("daemon-debian-1", "echo 'before'", timeout=5) assert result.success # Restart container subprocess.run( - ["docker", "restart", "sandd-daemon-1"], + ["docker", "restart", "sandd-daemon-debian-1"], check=True, capture_output=True ) # Wait for reconnection time.sleep(5) - reconnected = server.wait_for_daemon("daemon-1", timeout=15.0) + reconnected = server.wait_for_daemon("daemon-debian-1", timeout=15.0) assert reconnected # Execute command after restart - result = server.execute_command("daemon-1", "echo 'after'", timeout=5) + result = server.execute_command("daemon-debian-1", "echo 'after'", timeout=5) assert result.success assert "after" in result.stdout @@ -149,43 +172,63 @@ class TestE2EStats: def test_stats_reflect_containers(self, server): """Verify stats show all container daemons""" stats = server.get_stats() - assert stats.total_daemons == 3 + assert stats.total_daemons >= 6 assert "linux" in [p.lower() for p in stats.by_platform.keys()] -class TestE2ECommandExecution: - """Test command execution across Docker daemons""" - def test_command_output(self, server): - """Verify command output from daemons""" - for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: - result = server.execute_command( - daemon_id, - "uname -s", - timeout=5 - ) - assert result.success - assert result.stdout.strip() == "Linux" +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 - def test_execute_install_command(self, server): - """Test executing an installation command on daemons""" - for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: - result = server.execute_command( - daemon_id, - "apt-get update && apt-get install -y curl", - timeout=30 - ) + 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 - assert result.success, f"Command failed on {daemon_id}: {result.stderr}" + 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 - for daemon_id in ["daemon-1", "daemon-2", "daemon-3"]: - result = server.execute_command( - daemon_id, - "curl --version", - timeout=5 - ) - assert result.success, f"Command failed on {daemon_id}: {result.stderr}" - assert "curl" in result.stdout + 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"]) From 880c4bc394777241cb9436088e33bca3ac3d21b7 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 07:10:19 +0800 Subject: [PATCH 5/8] fix git example Signed-off-by: kerthcet --- examples/install_git.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/examples/install_git.py b/examples/install_git.py index d6f0184..d49d411 100644 --- a/examples/install_git.py +++ b/examples/install_git.py @@ -111,20 +111,28 @@ def main(): print("Testing git functionality...") print("-" * 50) - # Create a test repo + # Create test directory + repo_path = "/tmp/test-repo" + result = server.execute_command(daemon_id, f"mkdir -p {repo_path}", timeout=10) + if result.success: + print(f"✓ Create test directory") + else: + print(f"✗ Create test directory: {result.stderr}") + return + + # Commands that run inside the test repo - use cwd parameter test_commands = [ - ("mkdir -p /tmp/test-repo && cd /tmp/test-repo", "Create test directory"), - ("git init", "Initialize git repo"), - ("git config user.name 'Test User'", "Configure user"), - ("git config user.email 'test@example.com'", "Configure email"), - ("echo 'Hello from SandD' > README.md", "Create file"), - ("git add README.md", "Stage file"), - ("git commit -m 'Initial commit'", "Create commit"), - ("git log --oneline", "Show commit"), + ("git init", "Initialize git repo", repo_path), + ("git config user.name 'Test User'", "Configure user", repo_path), + ("git config user.email 'test@example.com'", "Configure email", repo_path), + ("echo 'Hello from SandD' > README.md", "Create file", repo_path), + ("git add README.md", "Stage file", repo_path), + ("git commit -m 'Initial commit'", "Create commit", repo_path), + ("git log --oneline", "Show commit", repo_path), ] - for cmd, description in test_commands: - result = server.execute_command(daemon_id, cmd, timeout=10) + for cmd, description, cwd in test_commands: + result = server.execute_command(daemon_id, cmd, timeout=10, cwd=cwd) if result.success: print(f"✓ {description}") if "git log" in cmd: From 61e7b5e754b15c8ff0218f5314366f0c6d403e51 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 07:31:11 +0800 Subject: [PATCH 6/8] add example to install htop Signed-off-by: kerthcet --- examples/install_git.py | 156 ------------------------------------- examples/install_htop.py | 164 +++++++++++++++++++++++++++++++++++++++ python/tests/test_e2e.py | 18 ++--- 3 files changed, 173 insertions(+), 165 deletions(-) delete mode 100644 examples/install_git.py create mode 100755 examples/install_htop.py diff --git a/examples/install_git.py b/examples/install_git.py deleted file mode 100644 index d49d411..0000000 --- a/examples/install_git.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -"""Example: Installing git on daemons - -This example shows how to check if git is available and install it if needed. - -Usage: - python examples/install_git.py -""" - -from sandd import Server -import sys -import time - - -def check_git_available(server, daemon_id): - """Check if git is available on a daemon""" - result = server.execute_command(daemon_id, "which git", timeout=5) - return result.success - - -def install_git(server, daemon_id): - """Install git on a daemon using the system package manager""" - print(f"Installing git on {daemon_id}...") - - # Detect platform - 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().lower() - - # Determine install command - if "linux" in platform: - # Check if it's Debian/Ubuntu or RHEL/CentOS - distro_result = server.execute_command( - daemon_id, - "cat /etc/os-release 2>/dev/null || echo 'unknown'", - timeout=5 - ) - distro = distro_result.stdout.lower() - - if "ubuntu" in distro or "debian" in distro: - cmd = "sudo apt-get update && sudo apt-get install -y git" - elif "centos" in distro or "rhel" in distro or "fedora" in distro: - cmd = "sudo yum install -y git" - else: - # Default to apt for unknown Linux - cmd = "sudo apt-get update && sudo apt-get install -y git" - - elif "darwin" in platform: - cmd = "brew install git" - else: - print(f"❌ Unsupported platform: {platform}") - return False - - # Execute installation - result = server.execute_command(daemon_id, cmd, timeout=300) - - if result.success: - print("✓ git installed successfully") - return True - else: - print(f"❌ Failed to install git: {result.stderr}") - return False - - -def main(): - print("Git 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 git is available - print("Checking if git is available...") - if check_git_available(server, daemon_id): - print("✓ git is already installed") - - # Get git version - result = server.execute_command(daemon_id, "git --version", timeout=5) - print(f" Version: {result.stdout.strip()}") - else: - print("✗ git is not installed") - print() - - # Install git - if install_git(server, daemon_id): - # Verify installation - result = server.execute_command(daemon_id, "git --version", timeout=5) - if result.success: - print(f" Version: {result.stdout.strip()}") - else: - print("Failed to install git") - sys.exit(1) - - print() - - # Test git functionality - print("Testing git functionality...") - print("-" * 50) - - # Create test directory - repo_path = "/tmp/test-repo" - result = server.execute_command(daemon_id, f"mkdir -p {repo_path}", timeout=10) - if result.success: - print(f"✓ Create test directory") - else: - print(f"✗ Create test directory: {result.stderr}") - return - - # Commands that run inside the test repo - use cwd parameter - test_commands = [ - ("git init", "Initialize git repo", repo_path), - ("git config user.name 'Test User'", "Configure user", repo_path), - ("git config user.email 'test@example.com'", "Configure email", repo_path), - ("echo 'Hello from SandD' > README.md", "Create file", repo_path), - ("git add README.md", "Stage file", repo_path), - ("git commit -m 'Initial commit'", "Create commit", repo_path), - ("git log --oneline", "Show commit", repo_path), - ] - - for cmd, description, cwd in test_commands: - result = server.execute_command(daemon_id, cmd, timeout=10, cwd=cwd) - if result.success: - print(f"✓ {description}") - if "git log" in cmd: - print(f" {result.stdout.strip()}") - else: - print(f"✗ {description}: {result.stderr}") - - print() - print("=" * 50) - print("Example complete!") - - -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/examples/install_htop.py b/examples/install_htop.py new file mode 100755 index 0000000..92e3a8c --- /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(f"❌ 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 index d98c20d..124d45f 100644 --- a/python/tests/test_e2e.py +++ b/python/tests/test_e2e.py @@ -183,40 +183,40 @@ 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", + "apt-get update && apt-get install -y htop", timeout=60 ) assert result.success - result = server.execute_command("daemon-debian-1", "curl --version", timeout=5) + result = server.execute_command("daemon-debian-1", "htop --version", timeout=5) assert result.success - assert "curl" in result.stdout + assert "htop" in result.stdout.lower() 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", + "apk update && apk add htop", timeout=60 ) assert result.success - result = server.execute_command("daemon-alpine-1", "curl --version", timeout=5) + result = server.execute_command("daemon-alpine-1", "htop --version", timeout=5) assert result.success - assert "curl" in result.stdout + assert "htop" in result.stdout.lower() 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", + "microdnf install -y htop", timeout=60 ) assert result.success - result = server.execute_command("daemon-rocky-1", "curl --version", timeout=5) + result = server.execute_command("daemon-rocky-1", "htop --version", timeout=5) assert result.success - assert "curl" in result.stdout + assert "htop" in result.stdout.lower() def test_all_distros_run_same_command(self, server): """Verify all distributions can run common commands""" From fb775e9c8e3a2f4a945fe9bbb0fd5c9903914ca9 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 07:32:47 +0800 Subject: [PATCH 7/8] fix lint Signed-off-by: kerthcet --- examples/install_htop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/install_htop.py b/examples/install_htop.py index 92e3a8c..e1162bb 100755 --- a/examples/install_htop.py +++ b/examples/install_htop.py @@ -60,7 +60,7 @@ def install_htop(server, daemon_id): elif "fedora" in distro: cmd = "dnf install -y htop" else: - print(f"❌ Unknown Linux distribution") + print("❌ Unknown Linux distribution") return False # Execute installation From db31a2c3e15d7267aaa16a578f0802304443d8d6 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 2 Jun 2026 07:46:35 +0800 Subject: [PATCH 8/8] fix test Signed-off-by: kerthcet --- python/tests/test_e2e.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py index 124d45f..d98c20d 100644 --- a/python/tests/test_e2e.py +++ b/python/tests/test_e2e.py @@ -183,40 +183,40 @@ 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 htop", + "apt-get update && apt-get install -y curl", timeout=60 ) assert result.success - result = server.execute_command("daemon-debian-1", "htop --version", timeout=5) + result = server.execute_command("daemon-debian-1", "curl --version", timeout=5) assert result.success - assert "htop" in result.stdout.lower() + 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 htop", + "apk update && apk add curl", timeout=60 ) assert result.success - result = server.execute_command("daemon-alpine-1", "htop --version", timeout=5) + result = server.execute_command("daemon-alpine-1", "curl --version", timeout=5) assert result.success - assert "htop" in result.stdout.lower() + 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 htop", + "microdnf install -y curl", timeout=60 ) assert result.success - result = server.execute_command("daemon-rocky-1", "htop --version", timeout=5) + result = server.execute_command("daemon-rocky-1", "curl --version", timeout=5) assert result.success - assert "htop" in result.stdout.lower() + assert "curl" in result.stdout def test_all_distros_run_same_command(self, server): """Verify all distributions can run common commands"""