diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index a19718c..b1ccf05 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -12,12 +12,15 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: - version: "latest" + enable-cache: true - name: Set up Python run: uv python install 3.11 - name: Install all dependencies run: uv sync --all-extras + + - name: Install Playwright browsers + run: uv run playwright install chromium --with-deps diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index 4c61b51..8da59bb 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -35,9 +35,9 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: - version: "latest" + enable-cache: true - name: Set up Python run: uv python install 3.11 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2371956..99bd5ce 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,15 +9,15 @@ on: jobs: lint: name: Lint & Type Check - runs-on: ubuntu-slim + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: - version: "latest" + enable-cache: true - name: Set up Python run: uv python install 3.11 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 7933dc9..e77dbcb 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -30,9 +30,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - version: "latest" + uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.11 diff --git a/.github/workflows/test-env-ctrl.yml b/.github/workflows/test-env-ctrl.yml new file mode 100644 index 0000000..1c7184e --- /dev/null +++ b/.github/workflows/test-env-ctrl.yml @@ -0,0 +1,43 @@ +name: Test Environment Control + +on: + pull_request: + branches: [main] + paths: + - "packages/environment_control/**" + - "tests/integration/environment_control/**" + - "src/webarena_verified/environments/env_ctrl_client/**" + push: + branches: [main] + paths: + - "packages/environment_control/**" + - "tests/integration/environment_control/**" + - "src/webarena_verified/environments/env_ctrl_client/**" + +jobs: + test: + name: Test Environment Control + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Install Playwright browsers + run: uv run playwright install chromium --with-deps + + - name: Build Docker image + run: docker build -t webarena-verified:test . + + - name: Run environment control tests + run: uv run pytest tests/integration/environment_control/ -m docker --webarena-verified-docker-img webarena-verified:test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21978ad..fbe7163 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,9 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: - version: "latest" + enable-cache: true - name: Set up Python run: uv python install 3.11 @@ -25,8 +25,11 @@ jobs: - name: Install dependencies run: uv sync --all-extras + - name: Install Playwright browsers + run: uv run playwright install chromium --with-deps + - name: Build Docker image run: docker build -t webarena-verified:test . - name: Run tests - run: uv run pytest --webarena-verified-docker-img webarena-verified:test + run: uv run pytest --webarena-verified-docker-img webarena-verified:test --ignore=tests/integration/environment_control/ diff --git a/Dockerfile b/Dockerfile index f15c8b1..0d9a8a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # ============================================================ # Stage 1: Builder # ============================================================ -FROM ghcr.io/astral-sh/uv:0.9-python3.12-bookworm-slim AS builder +FROM ghcr.io/astral-sh/uv:0.9-python3.11-bookworm-slim AS builder WORKDIR /app @@ -26,7 +26,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================ # Stage 2: Runtime (minimal) # ============================================================ -FROM python:3.12-slim-bookworm AS runtime +FROM python:3.11-slim-bookworm AS runtime WORKDIR /app diff --git a/docs/assets/env-ctrl-dashboard.png b/docs/assets/env-ctrl-dashboard.png new file mode 100644 index 0000000..fe4bdaa Binary files /dev/null and b/docs/assets/env-ctrl-dashboard.png differ diff --git a/docs/getting_started/environments.md b/docs/environments/docker_images.md similarity index 93% rename from docs/getting_started/environments.md rename to docs/environments/docker_images.md index f04a384..053b4ff 100644 --- a/docs/getting_started/environments.md +++ b/docs/environments/docker_images.md @@ -1,6 +1,6 @@ -# Environments +# Docker Images -WebArena-Verified uses Docker containers to provide isolated, reproducible environments for each website in the benchmark. To improve reset time and reduce storage requirements, we provide recipes to create **slim images** - optimized versions of the original environments while keeping the exact content and functionality. +To improve reset time and reduce storage requirements, we provide recipes to create **slim images** - optimized versions of the original environments while keeping the exact content and functionality. ## Size Improvements @@ -105,11 +105,13 @@ docker exec admin-slim magento-init http://localhost:7780 ``` **Container not initializing:** + - Check container logs: `docker logs admin-slim` - Verify MAGENTO_BASE_URL is set correctly - Wait ~30 seconds for initialization to complete **Database reset not working:** + - Ensure archive exists: `docker exec admin-slim ls -lh /var/backups/mysql/data.tar.gz` - Check services status: `docker exec admin-slim supervisorctl status` @@ -123,6 +125,7 @@ bash create_slim_image.sh ``` **Note:** The script reuses existing data if available: + - PostgreSQL archive (~1.6GB) - Optimized submission images (~6GB) - This saves ~30-60 minutes on subsequent runs @@ -150,11 +153,13 @@ docker exec reddit-slim postmill-init ### Troubleshooting **Container not initializing:** + - Check container logs: `docker logs reddit-slim` - Wait ~2-3 minutes for initial data extraction - Check initialization status: `docker exec reddit-slim cat /run/postmill.env` **Database reset not working:** + - Ensure archives exist: ```bash docker exec reddit-slim ls -lh /var/backups/pgsql/data.tar.gz @@ -164,6 +169,7 @@ docker exec reddit-slim postmill-init - Verify database integrity: `docker exec reddit-slim postmill-init` (shows validation output) **Patches not applied:** + 1. Check rate limits removed: ```bash docker exec reddit-slim grep -c '@RateLimit' /var/www/html/src/DataObject/SubmissionData.php diff --git a/docs/environments/environment_control.md b/docs/environments/environment_control.md new file mode 100644 index 0000000..6b58247 --- /dev/null +++ b/docs/environments/environment_control.md @@ -0,0 +1,129 @@ +# Environment Control + +A lightweight utility for WebArena test environment containers that enables self-contained environment management. + +## Why? + +Without this package, setting up a test environment requires: + +1. Starting the Docker container +2. Using `docker exec` to run setup commands (e.g., configuring external URLs) + +This approach has two main issues: + +1. **Version mismatch** - Setup commands may change between versions, requiring maintenance of different setup scripts for different environment versions. +2. **Limited container access** - In some deployment scenarios (e.g., Kubernetes, managed container services), accessing the internal Docker daemon or running `docker exec` is either not allowed or difficult to configure. + +## How It Works + +The package is installed inside each test environment container and exposes environment management operations through two interfaces: + +- **CLI** - For direct access via `docker exec` +- **HTTP API** - For remote access without requiring Docker access + +This makes each test environment site fully self-contained and provides a consistent interface across all test environments (shopping, reddit, gitlab, etc.), regardless of their underlying implementation. + +## Architecture + +```mermaid +graph LR + subgraph "Host / Test Runner" + Client[WebArena Client] + end + + subgraph "Shopping Container" + App[Shopping Site] + EnvCtrl[env-ctrl server :8877] + end + + Client -->|"HTTP: /health, /reset, /config"| EnvCtrl + EnvCtrl -->|manages| App +``` + +The client library communicates with `env-ctrl` running inside each container, allowing operations like health checks, resets, and configuration changes without direct Docker access. + +## Dashboard + +The env-ctrl server includes a web-based dashboard for manual environment management. Access it by navigating to the server's root URL (e.g., `http://localhost:8877/`). + +![Environment Control Dashboard](../assets/env-ctrl-dashboard.png) + +The dashboard provides: + +- **Environment status** - Shows the current environment type and health status +- **Action buttons** - Initialize, Start, Stop, Restart, and Refresh Status +- **Console output** - Real-time feedback from operations + +This is useful for debugging and manual testing without needing to use the CLI or write code. + +## Usage + +### Via Docker Exec (CLI) + +When you have Docker access, you can use the CLI directly: + +```bash +# List available environment types +docker exec env-ctrl list + +# Check status +docker exec env-ctrl status + +# Start the HTTP server +docker exec env-ctrl serve --port 8877 +``` + +### Via HTTP API + +When the server is running, interact with it over HTTP: + +```bash +curl http://localhost:8877/health +curl -X POST http://localhost:8877/reset +``` + +### Python Client + +For programmatic access, use the client classes from the `webarena_verified` library: + +```python +from webarena_verified.environments.env_ctrl_client import HttpClient + +# Connect to the env-ctrl server +client = HttpClient(base_url="http://localhost:8877") + +# Check environment status +result = client.status() +print(result["success"]) # True if healthy + +# Wait for environment to be ready +result = client.wait_until_ready(timeout=60.0) + +# Start/stop the environment +client.start(wait=True) +client.stop() +``` + +**Available methods:** + +| Method | Description | +|--------|-------------| +| `status()` | Get environment health status | +| `init()` | Initialize the environment | +| `start(wait=False)` | Start the environment | +| `stop()` | Stop the environment | +| `restart(wait=False)` | Restart the environment | +| `wait_until_ready(timeout, interval)` | Poll until environment is ready | +| `cleanup()` | Remove logs, caches, temp files | +| `config()` | Get build configuration | + +**Alternative: Docker client** + +If you have Docker access and prefer to use `docker exec` instead of HTTP: + +```python +from webarena_verified.environments.env_ctrl_client import EnvCtrlDockerClient + +client = EnvCtrlDockerClient(container_name="shopping-slim") +result = client.status() +``` diff --git a/docs/environments/index.md b/docs/environments/index.md new file mode 100644 index 0000000..48976ca --- /dev/null +++ b/docs/environments/index.md @@ -0,0 +1,16 @@ +# Environments + +WebArena-Verified uses Docker containers to provide isolated, reproducible test environments for each website in the benchmark. + +## Overview + +Each test environment (Shopping, Reddit, GitLab, etc.) runs as a self-contained Docker container with: + +- The application and its dependencies +- Pre-populated test data +- Environment control utilities for management via CLI or HTTP + +## Sections + +- [Docker Images](docker_images.md) - Instructions for building and running slim Docker images +- [Environment Control](environment_control.md) - The `environment_control` package for managing environments diff --git a/mkdocs.yml b/mkdocs.yml index fb6733d..a4eeca7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,13 +57,18 @@ plugins: - redirects: redirect_maps: getting_started/index.md: index.md + getting_started/environments.md: environments/index.md markdown_extensions: - admonition - pymdownx.details - attr_list - md_in_html - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite @@ -94,10 +99,13 @@ nav: - Configuration: getting_started/configuration.md - Subset Manager: getting_started/subset_manager.md - Utilities: getting_started/utilities.md - - Environments: getting_started/environments.md - Changelog: changelog/index.md - Subsets: - WebArena-Verified Hard: getting_started/hard_subset.md + - Environments: + - Overview: environments/index.md + - Docker Images: environments/docker_images.md + - Environment Control: environments/environment_control.md - Evaluation: - Quick Start: evaluation/index.md - Handling of Unachievable Tasks: evaluation/handling_of_unachievable_tasks.md diff --git a/packages/environment_control/README.md b/packages/environment_control/README.md new file mode 100644 index 0000000..3d545e3 --- /dev/null +++ b/packages/environment_control/README.md @@ -0,0 +1,79 @@ +# environment_control + +A lightweight utility for WebArena test environment containers that enables self-contained environment management. + +## Why? + +Without this package, setting up a test environment requires: +1. Starting the Docker container +2. Using `docker exec` to run setup commands (e.g., configuring external URLs) + +This approach has two main issues: + +1. **Version mismatch** - Setup commands may change between versions, requiring maintenance of different setup scripts for different environment versions. +2. **Limited container access** - In some deployment scenarios (e.g., Kubernetes, managed container services), accessing the internal Docker daemon or running `docker exec` is either not allowed or difficult to configure. + +## How It Works + +The package is installed inside each test environment container and exposes environment management operations through two interfaces: + +- **CLI** - For direct access via `docker exec` +- **HTTP API** - For remote access without requiring Docker access + +This makes each test environment site fully self-contained and provides a consistent interface across all test environments (shopping, reddit, gitlab, etc.), regardless of their underlying implementation. + +## Architecture + +```mermaid +graph LR + subgraph "Host / Test Runner" + Client[WebArena Client] + end + + subgraph "Shopping Container" + App[Shopping Site] + EnvCtrl[env-ctrl server :8877] + end + + Client -->|"HTTP: /health, /reset, /config"| EnvCtrl + EnvCtrl -->|manages| App +``` + +The client library communicates with `env-ctrl` running inside each container, allowing operations like health checks, resets, and configuration changes without direct Docker access. + +## Installation + +```bash +pip install . +``` + +## Usage + +### Via Docker Exec (CLI) + +When you have Docker access, you can use the CLI directly: + +```bash +# List available environment types +docker exec env-ctrl list + +# Check status +docker exec env-ctrl status + +# Start the HTTP server +docker exec env-ctrl serve --port 8877 +``` + +### Via HTTP API + +When the server is running, interact with it over HTTP: + +```bash +curl http://localhost:8877/status +curl -X POST http://localhost:8877/init +curl -X POST http://localhost:8877/start +curl -X POST http://localhost:8877/stop +curl -X POST http://localhost:8877/restart +``` + +For programmatic access, use the client classes from the main `webarena_verified` library (see `webarena_verified.environments.env_ctrl_client`). diff --git a/packages/environment_control/environment_control/__init__.py b/packages/environment_control/environment_control/__init__.py new file mode 100644 index 0000000..02d7ef8 --- /dev/null +++ b/packages/environment_control/environment_control/__init__.py @@ -0,0 +1,33 @@ +"""Environment control package for Docker containers. + +This package provides CLI, REST API, and Python client interfaces for +controlling Docker container environments. + +Example usage: + # CLI + env-ctrl status + env-ctrl start --wait + + # Python API + from environment_control.ops import get_ops_class + ops = get_ops_class("shopping_admin") + result = ops.get_health() +""" + +from __future__ import annotations + +from .ops import BaseOps, DummyOps, ExecLog, Health, OpsConfig, Result, ServiceState, ShoppingAdminOps, get_ops_class + +__version__ = "0.1.0" + +__all__ = [ + "BaseOps", + "DummyOps", + "ExecLog", + "Health", + "OpsConfig", + "Result", + "ServiceState", + "ShoppingAdminOps", + "get_ops_class", +] diff --git a/packages/environment_control/environment_control/__main__.py b/packages/environment_control/environment_control/__main__.py new file mode 100644 index 0000000..9aafd58 --- /dev/null +++ b/packages/environment_control/environment_control/__main__.py @@ -0,0 +1,14 @@ +"""CLI entry point for running as a module. + +Usage: + python -m environment_control [options] +""" + +from __future__ import annotations + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/environment_control/environment_control/_internal/__init__.py b/packages/environment_control/environment_control/_internal/__init__.py new file mode 100644 index 0000000..7a379e9 --- /dev/null +++ b/packages/environment_control/environment_control/_internal/__init__.py @@ -0,0 +1,8 @@ +"""Internal utilities for environment_control package.""" + +from __future__ import annotations + +from .config import Config, get_config +from .logging import configure_logging + +__all__ = ["Config", "configure_logging", "get_config"] diff --git a/packages/environment_control/environment_control/_internal/config.py b/packages/environment_control/environment_control/_internal/config.py new file mode 100644 index 0000000..5343a3b --- /dev/null +++ b/packages/environment_control/environment_control/_internal/config.py @@ -0,0 +1,31 @@ +"""Configuration management for environment_control package.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class Config: + """Configuration for the environment control server.""" + + env_type: Optional[str] = None + port: int = 8877 + site_url: Optional[str] = None + + @classmethod + def from_env(cls) -> "Config": + """Read configuration from environment variables.""" + return cls( + env_type=os.environ.get("WA_ENV_CTRL_TYPE"), + port=int(os.environ.get("WA_ENV_CTRL_PORT", cls.port)), + site_url=os.environ.get("WA_ENV_CTRL_EXTERNAL_SITE_URL"), + ) + + +# Backwards compatibility +def get_config() -> Config: + """Read configuration from environment variables.""" + return Config.from_env() diff --git a/packages/environment_control/environment_control/_internal/logging.py b/packages/environment_control/environment_control/_internal/logging.py new file mode 100644 index 0000000..b190951 --- /dev/null +++ b/packages/environment_control/environment_control/_internal/logging.py @@ -0,0 +1,101 @@ +"""Logging configuration for environment_control package.""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Optional + +DEFAULT_LOG_FILE = "/tmp/env-ctrl.log" +DEFAULT_LOG_LEVEL = "INFO" +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +_configured = False + + +def configure_logging( + log_file: Optional[str] = None, + log_level: Optional[str] = None, + force: bool = False, +) -> None: + """Configure logging for the environment_control package. + + This function configures the 'environment_control' logger with file and + console handlers. It should be called once at application startup. + + Args: + log_file: Path to log file. Defaults to ENV_CTRL_LOG_FILE env var + or /tmp/env-ctrl.log. + log_level: Log level string (DEBUG, INFO, WARNING, ERROR, CRITICAL). + Defaults to ENV_CTRL_LOG_LEVEL env var or INFO. + force: If True, reconfigure even if already configured. + """ + global _configured + + if _configured and not force: + return + + if log_file is None: + log_file = os.environ.get("ENV_CTRL_LOG_FILE", DEFAULT_LOG_FILE) + + if log_level is None: + log_level = os.environ.get("ENV_CTRL_LOG_LEVEL", DEFAULT_LOG_LEVEL) + + # Get the package logger + logger = logging.getLogger("environment_control") + level = getattr(logging, log_level.upper(), logging.INFO) + logger.setLevel(level) + + # Clear existing handlers to avoid duplicates + logger.handlers.clear() + + formatter = logging.Formatter(LOG_FORMAT) + + # File handler + try: + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + except OSError as e: + # Log to stderr if file creation fails + print(f"Warning: Could not create log file {log_file}: {e}", file=sys.stderr) + + # Console handler (stderr) + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # Prevent propagation to root logger to avoid duplicate logs + logger.propagate = False + + _configured = True + + +# Backwards compatibility aliases +def setup_logging( + log_file: Optional[str] = None, + log_level: Optional[str] = None, +) -> logging.Logger: + """Set up logging configuration (backwards compatible). + + Args: + log_file: Path to log file. + log_level: Log level string. + + Returns: + Configured logger instance. + """ + configure_logging(log_file=log_file, log_level=log_level) + return logging.getLogger("environment_control") + + +def get_logger() -> logging.Logger: + """Get the package logger (backwards compatible). + + Returns: + Logger instance. + """ + return logging.getLogger("environment_control") diff --git a/packages/environment_control/environment_control/cli.py b/packages/environment_control/environment_control/cli.py new file mode 100644 index 0000000..383b63f --- /dev/null +++ b/packages/environment_control/environment_control/cli.py @@ -0,0 +1,266 @@ +"""Command-line interface for environment control.""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from typing import Optional + +from ._internal.logging import configure_logging +from .ops import Result, get_ops_class, list_ops +from .ops.types import OpsConfig +from .server.app import run_server + +logger = logging.getLogger(__name__) + + +def _format_result(result: Result, verbose: bool = False) -> str: + """Format a Result for display. + + Args: + result: The Result object. + verbose: If True, show full details as JSON. + + Returns: + Formatted string for display. + """ + if verbose: + return json.dumps(result.to_dict(), indent=2) + + status = "OK" if result.success else "FAILED" + # Get last error from exec_logs if failed + error = "" + if not result.success and result.exec_logs: + last = result.exec_logs[-1] + error = f": {last.output}" + return f"[{status}]{error}" + + +def cmd_init(args: argparse.Namespace) -> int: + """Handle the init command.""" + ops = get_ops_class(args.env_type) + dry_run = getattr(args, "dry_run", False) + base_url = getattr(args, "base_url", None) + result = ops.init(base_url=base_url, dry_run=dry_run) + print(_format_result(result, args.verbose)) + return 0 if result.success else 1 + + +def cmd_status(args: argparse.Namespace) -> int: + """Handle the status command.""" + ops = get_ops_class(args.env_type) + result = ops.get_health() + print(_format_result(result, args.verbose)) + return 0 if result.success else 1 + + +def cmd_start(args: argparse.Namespace) -> int: + """Handle the start command.""" + ops = get_ops_class(args.env_type) + + if args.wait: + print("Starting environment and waiting for ready state...") + + config = OpsConfig(timeout_sec=int(args.timeout)) + result = ops.start(wait=args.wait, config=config) + print(_format_result(result, args.verbose)) + return 0 if result.success else 1 + + +def cmd_stop(args: argparse.Namespace) -> int: + """Handle the stop command.""" + ops = get_ops_class(args.env_type) + result = ops.stop() + print(_format_result(result, args.verbose)) + return 0 if result.success else 1 + + +def cmd_restart(args: argparse.Namespace) -> int: + """Handle the restart command.""" + ops = get_ops_class(args.env_type) + + if args.wait: + print("Restarting environment and waiting for ready state...") + + config = OpsConfig(timeout_sec=int(args.timeout)) + result = ops.restart(wait=args.wait, config=config) + print(_format_result(result, args.verbose)) + return 0 if result.success else 1 + + +def cmd_serve(args: argparse.Namespace) -> int: + """Handle the serve command.""" + run_server(env_type=args.env_type, port=args.port) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + """Handle the list command.""" + environments = list_ops() + print("Available environment types:") + for name in sorted(environments.keys()): + print(f" - {name}") + return 0 + + +def create_parser() -> argparse.ArgumentParser: + """Create the argument parser. + + Returns: + Configured ArgumentParser instance. + """ + parser = argparse.ArgumentParser( + prog="env-ctrl", + description="Environment control CLI for Docker containers", + ) + + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show verbose output with full JSON details", + ) + + parser.add_argument( + "-e", + "--env-type", + dest="env_type", + default=None, + help="Environment type (default: WA_ENV_CTRL_TYPE env var)", + ) + + subparsers = parser.add_subparsers( + title="commands", + dest="command", + required=True, + ) + + # init command + init_parser = subparsers.add_parser( + "init", + help="Initialize the environment", + ) + init_parser.add_argument( + "--base-url", + help="Base URL for the environment", + ) + init_parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without applying them", + ) + init_parser.set_defaults(func=cmd_init) + + # status command + status_parser = subparsers.add_parser( + "status", + help="Get environment status", + ) + status_parser.set_defaults(func=cmd_status) + + # start command + start_parser = subparsers.add_parser( + "start", + help="Start the environment", + ) + start_parser.add_argument( + "-w", + "--wait", + action="store_true", + help="Wait until environment is ready", + ) + start_parser.add_argument( + "-t", + "--timeout", + type=float, + default=60.0, + help="Timeout for waiting (default: 60s)", + ) + start_parser.set_defaults(func=cmd_start) + + # stop command + stop_parser = subparsers.add_parser( + "stop", + help="Stop the environment", + ) + stop_parser.set_defaults(func=cmd_stop) + + # restart command + restart_parser = subparsers.add_parser( + "restart", + help="Restart the environment", + ) + restart_parser.add_argument( + "-w", + "--wait", + action="store_true", + help="Wait until environment is ready", + ) + restart_parser.add_argument( + "-t", + "--timeout", + type=float, + default=60.0, + help="Timeout for waiting (default: 60s)", + ) + restart_parser.set_defaults(func=cmd_restart) + + # serve command + serve_parser = subparsers.add_parser( + "serve", + help="Start the REST API server", + ) + serve_parser.add_argument( + "-p", + "--port", + type=int, + default=None, + help="Port to listen on (default: WA_ENV_CTRL_PORT or 8080)", + ) + serve_parser.set_defaults(func=cmd_serve) + + # list command + list_parser = subparsers.add_parser( + "list", + help="List available environment types", + ) + list_parser.set_defaults(func=cmd_list) + + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + """Main entry point for the CLI. + + Args: + argv: Command-line arguments. Defaults to sys.argv[1:]. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + configure_logging() + + parser = create_parser() + args = parser.parse_args(argv) + + try: + logger.debug("Executing command: %s", args.command) + return args.func(args) + except ValueError as e: + logger.error("Error: %s", e) + print(f"Error: {e}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + logger.info("Interrupted by user") + print("\nInterrupted", file=sys.stderr) + return 130 + except Exception as e: + logger.exception("Unexpected error: %s", e) + print(f"Unexpected error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/environment_control/environment_control/ops/__init__.py b/packages/environment_control/environment_control/ops/__init__.py new file mode 100644 index 0000000..99504e2 --- /dev/null +++ b/packages/environment_control/environment_control/ops/__init__.py @@ -0,0 +1,91 @@ +"""Operations module for container environments. + +This module provides the base class, types, and site-specific implementations +for container environment operations. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from .base import BaseOps +from .mixins import SupervisorMixin +from .sites import DummyOps, GitlabOps, MapOps, RedditOps, ShoppingAdminOps, ShoppingOps, WikipediaOps +from .types import CommandExecutor, ExecLog, Health, OpsConfig, Result, ServiceState + +logger = logging.getLogger(__name__) + +# Simple registry mapping environment type names to their Ops classes +_OPS_CLASSES: dict[str, type[BaseOps]] = { + "shopping_admin": ShoppingAdminOps, + "dummy": DummyOps, + "gitlab": GitlabOps, + "map": MapOps, + "reddit": RedditOps, + "shopping": ShoppingOps, + "wikipedia": WikipediaOps, +} + + +def get_ops_class(env_type: Optional[str] = None) -> type[BaseOps]: + """Get an Ops class by environment type. + + Args: + env_type: Environment type name. If not provided, reads from + WA_ENV_CTRL_TYPE environment variable. + + Returns: + The Ops class for the requested environment type. + + Raises: + ValueError: If the environment type is unknown or not specified. + """ + if env_type is None: + env_type = os.environ.get("WA_ENV_CTRL_TYPE") + logger.debug("Using WA_ENV_CTRL_TYPE from environment: %s", env_type) + + if env_type is None: + logger.error("Environment type not specified") + raise ValueError( + "Environment type not specified. Set WA_ENV_CTRL_TYPE environment variable " + f"or pass env_type parameter. Available types: {list(_OPS_CLASSES.keys())}" + ) + + if env_type not in _OPS_CLASSES: + logger.error("Unknown environment type: %s", env_type) + raise ValueError(f"Unknown environment type: {env_type}. Available types: {list(_OPS_CLASSES.keys())}") + + logger.debug("Returning ops class for: %s", env_type) + return _OPS_CLASSES[env_type] + + +def list_ops() -> dict[str, type[BaseOps]]: + """Get a copy of the registered environment ops. + + Returns: + Dict mapping environment type names to their Ops classes. + """ + return dict(_OPS_CLASSES) + + +__all__ = [ + "BaseOps", + "CommandExecutor", + "DummyOps", + "ExecLog", + "GitlabOps", + "Health", + "MapOps", + "OpsConfig", + "RedditOps", + "Result", + "ServiceState", + "ShoppingAdminOps", + "ShoppingOps", + "SupervisorMixin", + "WikipediaOps", + "get_ops_class", + "list_ops", +] diff --git a/packages/environment_control/environment_control/ops/base.py b/packages/environment_control/environment_control/ops/base.py new file mode 100644 index 0000000..ceb447c --- /dev/null +++ b/packages/environment_control/environment_control/ops/base.py @@ -0,0 +1,484 @@ +"""Base operations for containerized environments. + +This module is stdlib-only and can be used both: +- Inside container: by environment_control +- Outside container: by invoke tasks (wrapping commands with docker exec) +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import time +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +from .types import CommandExecutor, ExecLog, OpsConfig, Result + + +class BaseOps(ABC): + """Abstract base class for container environment operations. + + Subclasses must implement site-specific health checks and initialization, + and define the `expected_services` class attribute. + + All operations take an optional `exec_cmd` executor function that executes + commands and returns (returncode, stdout, stderr). If not provided, defaults + to subprocess execution. + """ + + # --- Class attributes (must be defined by subclasses) --- + + expected_services: ClassVar[frozenset[str]] + """Services expected to be running for this environment to be healthy.""" + + cleanup_paths: ClassVar[list[str]] = [] + """Paths to remove during image cleanup. Subclasses define site-specific paths.""" + + # --- env-ctrl configuration --- + + _ENV_CTRL_SERVICE: ClassVar[str] = "env-ctrl" + """Name of the optional environment control service.""" + + _ENV_CTRL_ENABLE_VAR: ClassVar[str] = "WA_ENV_CTRL_ENABLE" + """Environment variable to enable/disable env-ctrl.""" + + _ENV_CTRL_AVAILABLE_VAR: ClassVar[str] = "WA_ENV_CTRL_AVAILABLE" + """Environment variable indicating env-ctrl is installed in the image.""" + + # --- env-ctrl methods --- + + @classmethod + def _is_env_ctrl_enabled(cls) -> bool: + """Check if env-ctrl is enabled via environment variable. + + Returns False if WA_ENV_CTRL_AVAILABLE is not 'true' (not installed). + Otherwise returns True if WA_ENV_CTRL_ENABLE is 'true' (case-insensitive). + Defaults to True if WA_ENV_CTRL_ENABLE is not set. + """ + if os.environ.get(cls._ENV_CTRL_AVAILABLE_VAR, "false").lower() != "true": + return False + return os.environ.get(cls._ENV_CTRL_ENABLE_VAR, "true").lower() == "true" + + @classmethod + def _get_expected_services(cls) -> frozenset[str]: + """Get expected services, including env-ctrl if enabled. + + Returns the base expected_services plus env-ctrl service + if WA_ENV_CTRL_ENABLE is true. + """ + if cls._is_env_ctrl_enabled(): + return cls.expected_services | {cls._ENV_CTRL_SERVICE} + return cls.expected_services + + # --- Public API --- + + @classmethod + def init( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + dry_run: bool = False, + **kwargs: Any, + ) -> Result: + """Initialize environment with global retry. + + Calls _init() and retries the entire initialization from scratch on failure. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for retry count etc. Defaults to OpsConfig.from_env(). + dry_run: If True, preview changes without applying them. + **kwargs: Passed to _init() for site-specific parameters. + """ + cfg = config or OpsConfig.from_env() + all_logs: list[ExecLog] = [] + + for attempt in range(cfg.retry_count + 1): + result = cls._init(exec_cmd=exec_cmd, dry_run=dry_run, **kwargs) + all_logs.extend(result.exec_logs) + + if result.success: + return Result(success=True, value=result.value, exec_logs=all_logs) + + # Failed - wait before retry (unless dry-run or last attempt) + if dry_run or attempt >= cfg.retry_count: + break + time.sleep(cfg.retry_delay_sec) + + # All retries exhausted + return Result(success=False, exec_logs=all_logs) + + @classmethod + def start( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + wait: bool = False, + **kwargs: Any, + ) -> Result: + """Start environment services. + + Calls _start() to start services. If wait=True, polls get_health() + until healthy or timeout. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout etc. Defaults to OpsConfig.from_env(). + wait: If True, wait for services to become healthy. + **kwargs: Passed to _start() for site-specific parameters. + """ + all_logs: list[ExecLog] = [] + + result = cls._start(exec_cmd=exec_cmd, **kwargs) + all_logs.extend(result.exec_logs) + + if not result.success: + return Result(success=False, exec_logs=all_logs) + + if wait: + wait_result = cls._wait_until_healthy(exec_cmd=exec_cmd, config=config) + all_logs.extend(wait_result.exec_logs) + if not wait_result.success: + return Result(success=False, exec_logs=all_logs) + + return Result(success=True, exec_logs=all_logs) + + @classmethod + def stop( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + wait: bool = False, + **kwargs: Any, + ) -> Result: + """Stop environment services. + + Calls _stop() to stop services. If wait=True, polls until services + are stopped or timeout. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout etc. Defaults to OpsConfig.from_env(). + wait: If True, wait for services to stop. + **kwargs: Passed to _stop() for site-specific parameters. + """ + all_logs: list[ExecLog] = [] + + result = cls._stop(exec_cmd=exec_cmd, **kwargs) + all_logs.extend(result.exec_logs) + + if not result.success: + return Result(success=False, exec_logs=all_logs) + + if wait: + wait_result = cls._wait_until_stopped(exec_cmd=exec_cmd, config=config) + all_logs.extend(wait_result.exec_logs) + if not wait_result.success: + return Result(success=False, exec_logs=all_logs) + + return Result(success=True, exec_logs=all_logs) + + @classmethod + def restart( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + wait: bool = False, + **kwargs: Any, + ) -> Result: + """Restart environment services (stop + start). + + Stops services (waiting for them to stop), then starts them. + If wait=True, waits for services to become healthy after starting. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout etc. Defaults to OpsConfig.from_env(). + wait: If True, wait for services to become healthy after starting. + **kwargs: Passed to _stop() and _start() for site-specific parameters. + """ + all_logs: list[ExecLog] = [] + + # Stop and wait for services to stop + stop_result = cls.stop(exec_cmd=exec_cmd, config=config, wait=True, **kwargs) + all_logs.extend(stop_result.exec_logs) + + if not stop_result.success: + return Result(success=False, exec_logs=all_logs) + + # Start services (optionally wait for healthy) + start_result = cls.start(exec_cmd=exec_cmd, config=config, wait=wait, **kwargs) + all_logs.extend(start_result.exec_logs) + + if not start_result.success: + return Result(success=False, exec_logs=all_logs) + + return Result(success=True, exec_logs=all_logs) + + @classmethod + def get_health( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + **kwargs: Any, + ) -> Result: + """Get environment health with retry. + + Calls _get_health() and retries on failure. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for retry count etc. Defaults to OpsConfig.from_env(). + **kwargs: Passed to _get_health() for site-specific parameters. + """ + cfg = config or OpsConfig.from_env() + all_logs: list[ExecLog] = [] + result: Result | None = None + + for _attempt in range(cfg.retry_count + 1): + result = cls._get_health(exec_cmd=exec_cmd, **kwargs) + all_logs.extend(result.exec_logs) + + if result.success: + return Result(success=True, value=result.value, exec_logs=all_logs) + # Failed, will retry (if attempts remain) + + # All retries exhausted + return Result(success=False, value=result.value if result else None, exec_logs=all_logs) + + @classmethod + def cleanup( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + dry_run: bool = False, + **kwargs: Any, + ) -> Result: + """Remove cleanup_paths to reduce image size. + + Executes rm -rf on each path in cleanup_paths. This is a best-effort + operation - individual path failures don't fail the overall cleanup. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration. Defaults to OpsConfig.from_env(). + dry_run: If True, preview changes without applying them. + **kwargs: Additional arguments (unused, for API consistency). + + Returns: + Result with exec_logs for each cleanup command. + """ + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "paths_to_delete": cls.cleanup_paths, + "path_count": len(cls.cleanup_paths), + }, + exec_logs=[], + ) + + all_logs: list[ExecLog] = [] + + for path in cls.cleanup_paths: + cmd = f"rm -rf {path}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd, config) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + # Cleanup always succeeds (best effort) + return Result(success=True, exec_logs=all_logs) + + @classmethod + def _run_entrypoint(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Run container entrypoint to start services. Override in subclass. + + Called when supervisor is not running (e.g., container started with custom cmd). + Default implementation is a no-op. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + **kwargs: Site-specific parameters. + + Returns: + Result indicating if entrypoint was started successfully. + """ + return Result( + success=True, + value={"message": "No entrypoint configured"}, + exec_logs=[], + ) + + # --- Abstract methods (must be implemented by subclasses) --- + + @classmethod + @abstractmethod + def _init( + cls, exec_cmd: CommandExecutor | None = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Site-specific initialization logic. + + Subclasses implement this with their init commands. + Should fail fast on first error - retry is handled by init(). + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL for the site (e.g., "http://localhost:7780"). + dry_run: If True, preview changes without applying them. + **kwargs: Site-specific parameters. + """ + ... + + @classmethod + @abstractmethod + def _start(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Site-specific start logic. + + Subclasses implement this to start their services. + """ + ... + + @classmethod + @abstractmethod + def _stop(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Site-specific stop logic. + + Subclasses implement this to stop their services. + """ + ... + + @classmethod + @abstractmethod + def _get_health(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Site-specific health check logic. + + Subclasses implement this with their health check commands. + Should fail fast on first error - retry is handled by get_health(). + """ + ... + + # --- Shared helpers --- + + @classmethod + def _wait_until_healthy( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + poll_interval: float = 2.0, + ) -> Result: + """Wait until environment is healthy. + + Polls _get_health() until success or timeout. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout. Defaults to OpsConfig.from_env(). + poll_interval: Seconds between health checks. + """ + cfg = config or OpsConfig.from_env() + all_logs: list[ExecLog] = [] + deadline = time.time() + cfg.timeout_sec + logger = logging.getLogger(__name__) + + while time.time() < deadline: + result = cls._get_health(exec_cmd=exec_cmd) + all_logs.extend(result.exec_logs) + + if result.success: + return Result(success=True, exec_logs=all_logs) + + # Log what's not healthy + if result.value and hasattr(result.value, "services"): + unhealthy = [ + f"{name}={state.name}" for name, state in result.value.services.items() if state.name != "HEALTHY" + ] + if unhealthy: + logger.info("Waiting for services: %s", ", ".join(unhealthy)) + + time.sleep(poll_interval) + + return Result(success=False, exec_logs=all_logs) + + @classmethod + def _wait_until_stopped( + cls, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + poll_interval: float = 1.0, + ) -> Result: + """Wait until environment services are stopped. + + Polls _get_health() until failure (services down) or timeout. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout. Defaults to OpsConfig.from_env(). + poll_interval: Seconds between checks. + """ + cfg = config or OpsConfig.from_env() + all_logs: list[ExecLog] = [] + deadline = time.time() + cfg.timeout_sec + + while time.time() < deadline: + result = cls._get_health(exec_cmd=exec_cmd) + all_logs.extend(result.exec_logs) + + if not result.success: + # Services are down + return Result(success=True, exec_logs=all_logs) + + time.sleep(poll_interval) + + return Result(success=False, exec_logs=all_logs) + + @classmethod + def _check_http(cls, url: str, exec_cmd: CommandExecutor | None = None) -> Result: + """Check if HTTP endpoint is responding. + + Args: + url: URL to check + exec_cmd: Optional executor function. If None, uses subprocess. + + Returns: + Result with bool indicating if endpoint responded successfully. + """ + cmd = f"curl -sf --max-time 10 {url}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + # Log failure details for debugging + if returncode != 0: + logger = logging.getLogger(__name__) + logger.debug("curl failed: exit=%d, stderr=%s", returncode, stderr[:200] if stderr else "") + return Result( + success=returncode == 0, + value=returncode == 0, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _run_cmd( + cls, + cmd: str, + exec_cmd: CommandExecutor | None = None, + config: OpsConfig | None = None, + ) -> tuple[int, str, str]: + """Execute a command and return (returncode, stdout, stderr). + + Args: + cmd: Command string to execute + exec_cmd: Optional executor function. If None, uses subprocess. + config: Configuration for timeout etc. Defaults to OpsConfig.from_env(). + + Returns: + Tuple of (returncode, stdout, stderr) + """ + if exec_cmd is not None: + return exec_cmd(cmd) + + cfg = config or OpsConfig.from_env() + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=cfg.timeout_sec) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return 124, "", f"Command timed out after {cfg.timeout_sec}s" diff --git a/packages/environment_control/environment_control/ops/mixins/__init__.py b/packages/environment_control/environment_control/ops/mixins/__init__.py new file mode 100644 index 0000000..d8bc8da --- /dev/null +++ b/packages/environment_control/environment_control/ops/mixins/__init__.py @@ -0,0 +1,12 @@ +"""Mixins for container operations. + +This module is stdlib-only and can be used both: +- Inside container: by environment_control +- Outside container: by invoke tasks (wrapping commands with docker exec) +""" + +from __future__ import annotations + +from .supervisor import SupervisorMixin + +__all__ = ["SupervisorMixin"] diff --git a/packages/environment_control/environment_control/ops/mixins/supervisor.py b/packages/environment_control/environment_control/ops/mixins/supervisor.py new file mode 100644 index 0000000..dae522d --- /dev/null +++ b/packages/environment_control/environment_control/ops/mixins/supervisor.py @@ -0,0 +1,231 @@ +"""Supervisor mixin for container operations.""" + +from __future__ import annotations + +import logging +import time +from typing import ClassVar, Optional + +from ..types import CommandExecutor, ExecLog, OpsConfig, Result, ServiceState + + +class SupervisorMixin: + """Mixin providing supervisord operations. + + Requires the class to have: + - `run_cmd` classmethod (provided by BaseOps) + - `expected_services` class attribute (defined by BaseOps) + + Include this mixin for environments managed by supervisord. + """ + + expected_services: ClassVar[frozenset[str]] # Required by mixin, defined by BaseOps + + @classmethod + def is_supervisor_running( + cls, + socket_path: str, + exec_cmd: Optional[CommandExecutor] = None, + ) -> Result: + """Check if supervisord is running by testing for its socket. + + Args: + socket_path: Path to the supervisord socket (e.g., /run/supervisord.sock). + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with success=True if socket exists (supervisor running), + False otherwise. + """ + cmd = f"test -S {socket_path}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) # type: ignore[attr-defined] + return Result( + success=returncode == 0, + value=returncode == 0, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def wait_for_supervisord( + cls, + exec_cmd: Optional[CommandExecutor] = None, + config: OpsConfig | None = None, + poll_interval: float = 2.0, + ) -> Result: + """Wait for supervisord to be ready. + + Polls until the supervisord socket exists or timeout is reached. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + config: Configuration for timeout. Defaults to OpsConfig.from_env(). + poll_interval: Seconds between checks. + + Returns: + Result with success=True if supervisord is ready, False if timeout. + """ + logger = logging.getLogger(__name__) + cfg = config or OpsConfig.from_env() + all_logs: list[ExecLog] = [] + deadline = time.time() + cfg.timeout_sec + socket_path = "/run/supervisord.sock" + + cmd = f"test -S {socket_path}" + logger.info("Waiting for supervisord socket (%s)...", socket_path) + + while time.time() < deadline: + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) # type: ignore[attr-defined] + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + if returncode == 0: + elapsed = cfg.timeout_sec - (deadline - time.time()) + logger.info("Supervisord ready after %.1fs", elapsed) + return Result(success=True, exec_logs=all_logs) + + time.sleep(poll_interval) + + logger.error("Supervisord socket not ready after %ds", cfg.timeout_sec) + return Result(success=False, exec_logs=all_logs) + + @classmethod + def _run_supervisor( + cls, + subcommand: str, + exec_cmd: Optional[CommandExecutor] = None, + ) -> Result: + """Run a supervisorctl command after waiting for supervisord. + + Args: + subcommand: The supervisorctl subcommand (e.g., "status", "start all"). + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with exec_logs including wait and command execution. + """ + logger = logging.getLogger(__name__) + + # Wait for supervisord first + wait_result = cls.wait_for_supervisord(exec_cmd=exec_cmd) + if not wait_result.success: + logger.error("Supervisord is not running, cannot execute: supervisorctl %s", subcommand) + return Result(success=False, exec_logs=wait_result.exec_logs) + + # Run the supervisorctl command + cmd = f"supervisorctl {subcommand}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) # type: ignore[attr-defined] + + return Result( + success=returncode == 0, + value=(returncode, stdout, stderr), + exec_logs=[*wait_result.exec_logs, ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def supervisor_status(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Get status of all supervisord services. + + Returns dict mapping service name to ServiceState (RUNNING, STOPPED, FAILED). + Note: RUNNING means process is up, not necessarily healthy. + + supervisorctl exit codes: + - 0: All processes RUNNING + - 3: At least one process is not RUNNING (stopped, exited, etc.) + - Other: supervisorctl error + """ + result = cls._run_supervisor("status", exec_cmd=exec_cmd) + + # _run_supervisor returns success=False for non-zero exit codes, + # but we need the output to parse even when some processes are stopped (exit code 3) + if result.value is None: + # supervisord not running or other critical error + return Result(success=False, value={}, exec_logs=result.exec_logs) + + returncode, stdout, _stderr = result.value + services: dict[str, ServiceState] = {} + + # Exit code 0 or 3 are both valid - just different states + if returncode in (0, 3): + for line in stdout.strip().split("\n"): + if line: + parts = line.split() + if len(parts) >= 2: + status = parts[1].upper() + try: + services[parts[0]] = ServiceState(status) + except ValueError: + services[parts[0]] = ServiceState.UNKNOWN + + # Success if we were able to get status (exit code 0 or 3) + return Result(success=returncode in (0, 3), value=services, exec_logs=result.exec_logs) + + @classmethod + def supervisor_start(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Start all supervisord services.""" + result = cls._run_supervisor("start all", exec_cmd=exec_cmd) + return Result(success=result.success, exec_logs=result.exec_logs) + + @classmethod + def supervisor_stop(cls, exec_cmd: Optional[CommandExecutor] = None, exclude_env_ctrl: bool = True) -> Result: + """Stop supervisord services. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + exclude_env_ctrl: If True (default), keeps env-ctrl running so the API + remains accessible. Set to False to stop all services including env-ctrl. + + Returns: + Result indicating success/failure with exec_logs. + """ + # Get services to stop (all except env-ctrl-init which auto-exits) + status_result = cls.supervisor_status(exec_cmd) + if not status_result.success: + return Result(success=False, exec_logs=status_result.exec_logs) + + all_logs: list[ExecLog] = list(status_result.exec_logs) + services = status_result.value or {} + + # Filter out env-ctrl if we want to keep it running + env_ctrl_svc = "env-ctrl" + services_to_stop = [ + svc + for svc in services + if svc != "env-ctrl-init" # Never try to stop init (auto-exits) + and (not exclude_env_ctrl or svc != env_ctrl_svc) + ] + + if not services_to_stop: + return Result(success=True, exec_logs=all_logs) + + # Stop services one by one to ensure proper shutdown + cmd = f"supervisorctl stop {' '.join(services_to_stop)}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) # type: ignore[attr-defined] + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + return Result(success=returncode == 0, exec_logs=all_logs) + + @classmethod + def services_running(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if all expected services are running. + + Returns dict of expected services with their states. + Uses _get_expected_services() to include optional env-ctrl if enabled. + """ + result = cls.supervisor_status(exec_cmd) + if not result.success or result.value is None: + return Result( + success=False, + value={}, + exec_logs=result.exec_logs, + ) + + # Use dynamic expected services (includes env-ctrl if enabled) + expected_svcs = cls._get_expected_services() # type: ignore[attr-defined] + expected = {svc: result.value.get(svc, ServiceState.UNKNOWN) for svc in expected_svcs} + + all_running = all(state == ServiceState.RUNNING for state in expected.values()) + + return Result( + success=all_running, + value=expected, + exec_logs=result.exec_logs, + ) diff --git a/packages/environment_control/environment_control/ops/sites/__init__.py b/packages/environment_control/environment_control/ops/sites/__init__.py new file mode 100644 index 0000000..b5d0c80 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/__init__.py @@ -0,0 +1,13 @@ +"""Site-specific operations modules.""" + +from __future__ import annotations + +from .dummy import DummyOps +from .gitlab import GitlabOps +from .map import MapOps +from .reddit import RedditOps +from .shopping import ShoppingOps +from .shopping_admin import ShoppingAdminOps +from .wikipedia import WikipediaOps + +__all__ = ["DummyOps", "GitlabOps", "MapOps", "RedditOps", "ShoppingAdminOps", "ShoppingOps", "WikipediaOps"] diff --git a/packages/environment_control/environment_control/ops/sites/dummy.py b/packages/environment_control/environment_control/ops/sites/dummy.py new file mode 100644 index 0000000..dc7c422 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/dummy.py @@ -0,0 +1,97 @@ +"""Dummy environment operations for testing. + +No external dependencies - returns configurable results based on environment variables. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class DummyOps(BaseOps): + """Dummy ops for testing - no external dependencies. + + Behavior is controlled via environment variables: + DUMMY_HEALTHY: "1" (default) for healthy, "0" for unhealthy + DUMMY_INIT_FAIL: "0" (default) for success, "1" for failure + DUMMY_START_FAIL: "0" (default) for success, "1" for failure + DUMMY_STOP_FAIL: "0" (default) for success, "1" for failure + + Usage: + WA_ENV_CTRL_TYPE=dummy env-ctrl status + DUMMY_HEALTHY=0 WA_ENV_CTRL_TYPE=dummy env-ctrl status # unhealthy + """ + + expected_services: ClassVar[frozenset[str]] = frozenset({"service-a", "service-b", "service-c"}) + + @classmethod + def _get_health(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Return health based on DUMMY_HEALTHY env var.""" + healthy = os.environ.get("DUMMY_HEALTHY", "1") == "1" + + if healthy: + services = { + "service-a": ServiceState.HEALTHY, + "service-b": ServiceState.HEALTHY, + "service-c": ServiceState.RUNNING, + } + else: + services = { + "service-a": ServiceState.UNHEALTHY, + "service-b": ServiceState.STOPPED, + "service-c": ServiceState.RUNNING, + } + + return Result( + success=healthy, + value=Health(services=services), + exec_logs=[ExecLog("dummy-health-check", 0 if healthy else 1, "dummy output", "")], + ) + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Return init result based on DUMMY_INIT_FAIL env var.""" + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "message": "Dummy init - no operations", + "commands_to_run": [], + "command_count": 0, + }, + exec_logs=[], + ) + + fail = os.environ.get("DUMMY_INIT_FAIL", "0") == "1" + + return Result( + success=not fail, + exec_logs=[ExecLog("dummy-init", 1 if fail else 0, "dummy init output", "")], + ) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Return start result based on DUMMY_START_FAIL env var.""" + fail = os.environ.get("DUMMY_START_FAIL", "0") == "1" + + return Result( + success=not fail, + exec_logs=[ExecLog("dummy-start", 1 if fail else 0, "dummy start output", "")], + ) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Return stop result based on DUMMY_STOP_FAIL env var.""" + fail = os.environ.get("DUMMY_STOP_FAIL", "0") == "1" + + return Result( + success=not fail, + exec_logs=[ExecLog("dummy-stop", 1 if fail else 0, "dummy stop output", "")], + ) diff --git a/packages/environment_control/environment_control/ops/sites/gitlab.py b/packages/environment_control/environment_control/ops/sites/gitlab.py new file mode 100644 index 0000000..fd87388 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/gitlab.py @@ -0,0 +1,363 @@ +"""GitLab environment operations. + +Discovered via exploration: +- Process manager: gitlab-ctl (runsvdir/runit) +- Services: postgresql, redis, puma, nginx, gitaly, gitlab-workhorse, sidekiq, etc. +- Cleanup paths: logs, prometheus data, tmp files, package caches +- Base URL config: Update external_url in /etc/gitlab/gitlab.rb + gitlab-ctl reconfigure + +Image size breakdown (77.6GB original): +- /var/opt/gitlab/git-data/repositories = 13GB (git data - keep) +- /var/opt/gitlab/postgresql/data = 12GB (database - keep) +- /var/opt/gitlab/gitlab-rails/uploads = 3.4GB (uploads - keep) +- /var/opt/gitlab/prometheus/data = 1.9GB (can clean) +- /opt/gitlab/embedded = 2.7GB (binaries - keep) +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar + +from ..base import BaseOps +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class GitlabOps(BaseOps): + """Operations for GitLab environment. + + GitLab uses gitlab-ctl (runsvdir/runit) for service management. + Services are managed via `gitlab-ctl start/stop/status` commands. + """ + + # =========================================== + # Services discovered from gitlab-ctl status + # =========================================== + # Note: logrotate excluded - it's a periodic task, not a continuous service + expected_services: ClassVar[frozenset[str]] = frozenset( + { + "alertmanager", + "gitaly", + "gitlab-exporter", + "gitlab-kas", + "gitlab-workhorse", + "nginx", + "postgres-exporter", + "postgresql", + "prometheus", + "puma", + "redis", + "redis-exporter", + "sidekiq", + "sshd", + } + ) + + # =========================================== + # Cleanup paths for image optimization + # =========================================== + cleanup_paths: ClassVar[list[str]] = [ + # GitLab logs + "/var/log/gitlab/*/*.log", + "/var/log/gitlab/*/*/*.log", + # System logs + "/var/log/*.log", + "/var/log/*/*.log", + # Prometheus data (monitoring - not needed for testing) + "/var/opt/gitlab/prometheus/data/*", + # GitLab tmp/cache + "/var/opt/gitlab/gitlab-rails/shared/tmp/*", + "/var/opt/gitlab/gitlab-rails/shared/cache/*", + # Backups + "/var/opt/gitlab/backups/*", + # Package caches + "/var/cache/apt/archives/*", + "/var/lib/apt/lists/*", + # Temp files + "/tmp/*", + ] + + # =========================================== + # Site constants + # =========================================== + GITLAB_CONFIG = "/etc/gitlab/gitlab.rb" + DEFAULT_PORT = 8023 + + # =========================================== + # Subclassed methods (BaseOps implementations) + # =========================================== + + @classmethod + def _init( + cls, exec_cmd: CommandExecutor | None = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize GitLab with base URL. + + Updates external_url in gitlab.rb and runs gitlab-ctl reconfigure. + Note: reconfigure can take several minutes. + + If WA_ENV_CTRL_SKIP_RECONFIGURE=true, skips reconfigure to avoid 502 errors. + This is useful when the image is pre-configured with the correct external_url. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL for GitLab (e.g., "http://localhost:8023") + dry_run: If True, preview changes without applying them. + """ + if not base_url: + raise ValueError("base_url is required") + + # Check if reconfigure should be skipped + skip_reconfigure = os.environ.get("WA_ENV_CTRL_SKIP_RECONFIGURE", "").lower() == "true" + + if skip_reconfigure: + return Result( + success=True, + value={"message": "Skipped reconfigure (WA_ENV_CTRL_SKIP_RECONFIGURE=true)"}, + exec_logs=[], + ) + + commands = [ + f"sed -i \"s|^external_url.*|external_url '{base_url}'|\" {cls.GITLAB_CONFIG}", + "gitlab-ctl reconfigure", + ] + + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "base_url": base_url, + "commands_to_run": commands, + "command_count": len(commands), + }, + exec_logs=[], + ) + + logs: list[ExecLog] = [] + for cmd in commands: + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + logs.append(ExecLog(cmd, returncode, stdout, stderr)) + if returncode != 0: + return Result(success=False, exec_logs=logs) + + return Result(success=True, exec_logs=logs) + + @classmethod + def _start(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Start all GitLab services via gitlab-ctl. + + Note: The container entrypoint runs /assets/wrapper which handles runsvdir + startup and gitlab-ctl reconfigure. This method just ensures services are + started if they're not already running. + """ + all_logs: list[ExecLog] = [] + + # Check current service status + cmd = "gitlab-ctl status" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + # If runsv not running, the container isn't ready yet (wrapper still starting) + # Return success=True to let the wait loop poll until ready + if "runsv not running" in stdout or "runsv not running" in stderr: + return Result(success=True, exec_logs=all_logs) + + # Count running services + running_count = stdout.count("run:") + expected_count = len(cls.expected_services) + + # If most services are running, consider startup successful + if running_count >= expected_count * 0.8: # 80% threshold + return Result(success=True, exec_logs=all_logs) + + # Some services are down, try to start them + cmd = "gitlab-ctl start" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + return Result( + success=returncode == 0, + exec_logs=all_logs, + ) + + @classmethod + def _stop(cls, exec_cmd: CommandExecutor | None = None, **kwargs: Any) -> Result: + """Stop all GitLab services via gitlab-ctl.""" + cmd = "gitlab-ctl stop" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + return Result( + success=returncode == 0, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _get_health(cls, exec_cmd: CommandExecutor | None = None, http_url: str | None = None, **kwargs: Any) -> Result: + """Check all GitLab services are healthy. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + http_url: Optional HTTP URL to check. Defaults to localhost:{DEFAULT_PORT}. + + Returns: + Result with Health containing all service states. + """ + all_logs: list[ExecLog] = [] + services: dict[str, ServiceState] = {} + + # Check gitlab-ctl status for all services + svc_result = cls._check_gitlab_services(exec_cmd) + if svc_result.value: + services.update(svc_result.value) + all_logs.extend(svc_result.exec_logs) + + # Additional health checks for core services + if services.get("postgresql") == ServiceState.RUNNING: + result = cls._check_postgresql(exec_cmd) + all_logs.extend(result.exec_logs) + services["postgresql"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + + if services.get("redis") == ServiceState.RUNNING: + result = cls._check_redis(exec_cmd) + all_logs.extend(result.exec_logs) + services["redis"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + + # Check env-ctrl if enabled (custom runit service, not in gitlab-ctl) + if cls._is_env_ctrl_enabled(): + result = cls._check_env_ctrl(exec_cmd) + all_logs.extend(result.exec_logs) + services["env-ctrl"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + + # HTTP check - verify GitLab redirects to login page + # This ensures GitLab's Rails app is fully initialized (not just nginx returning 502) + # We check that the root URL redirects to /users/sign_in which indicates GitLab is ready + effective_url = http_url or f"http://localhost:{cls.DEFAULT_PORT}" + http_result = cls._check_http_redirects_to_login(effective_url, exec_cmd) + all_logs.extend(http_result.exec_logs) + services["http"] = ServiceState.HEALTHY if http_result.value else ServiceState.UNHEALTHY + + health = Health(services=services) + return Result(success=health.is_healthy, value=health, exec_logs=all_logs) + + # =========================================== + # GitLab-specific helper methods + # =========================================== + + @classmethod + def _check_gitlab_services(cls, exec_cmd: CommandExecutor | None = None) -> Result: + """Check gitlab-ctl status and parse service states. + + Returns: + Result with dict mapping service names to ServiceState. + """ + cmd = "gitlab-ctl status" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + + services: dict[str, ServiceState] = {} + + # Parse gitlab-ctl status output + # Format: "run: servicename: (pid XXXX) XXXXs; run: log: (pid XXXX) XXXXs" + # or: "down: servicename: XXXs, normally up" + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + + # Check for running service + if line.startswith("run:"): + # Extract service name: "run: nginx: (pid 409) 589s" + parts = line.split(":") + if len(parts) >= 2: + service_name = parts[1].strip() + # Skip "log" entries + if service_name != "log" and service_name in cls.expected_services: + services[service_name] = ServiceState.RUNNING + + # Check for down service + elif line.startswith("down:"): + parts = line.split(":") + if len(parts) >= 2: + service_name = parts[1].strip() + if service_name in cls.expected_services: + services[service_name] = ServiceState.STOPPED + + return Result( + success=returncode == 0, + value=services, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_postgresql(cls, exec_cmd: CommandExecutor | None = None) -> Result: + """Check if PostgreSQL is responding.""" + cmd = 'gitlab-psql -d gitlabhq_production -c "SELECT 1;"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + # Success if command returns 0 and output contains "1" + ready = returncode == 0 and "1" in stdout + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_redis(cls, exec_cmd: CommandExecutor | None = None) -> Result: + """Check if Redis is responding.""" + cmd = "gitlab-redis-cli ping" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 and "PONG" in stdout.upper() + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_env_ctrl(cls, exec_cmd: CommandExecutor | None = None) -> Result: + """Check if env-ctrl process is running. + + env-ctrl runs as an independent background process (not a runit service), + so we check it via pgrep. + """ + cmd = "pgrep -f 'env-ctrl serve'" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + # pgrep returns 0 if process found, 1 if not found + running = returncode == 0 and stdout.strip() != "" + return Result( + success=running, + value=running, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_http_redirects_to_login( + cls, + url: str, + exec_cmd: CommandExecutor | None = None, + ) -> Result: + """Check that GitLab root URL redirects to the login page. + + GitLab returns 502 during startup while Puma workers initialize. + Once fully ready, the root URL redirects to /users/sign_in. + This check ensures GitLab is fully operational before marking healthy. + + Args: + url: Base URL to check (e.g., http://localhost:8023). + exec_cmd: Optional executor function. + + Returns: + Result with bool indicating if redirect to login page was detected. + """ + # Use curl -L to follow redirects and -w to output the final URL + # -s for silent, -o /dev/null to discard body, -w '%{url_effective}' for final URL + cmd = f"curl -sL -o /dev/null -w '%{{url_effective}}' --max-time 10 {url}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + + # Check if the final URL contains /users/sign_in (login page) + ready = returncode == 0 and "/users/sign_in" in stdout + + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) diff --git a/packages/environment_control/environment_control/ops/sites/map.py b/packages/environment_control/environment_control/ops/sites/map.py new file mode 100644 index 0000000..4faf639 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/map.py @@ -0,0 +1,380 @@ +"""OpenStreetMap website environment operations. + +Multi-service environment managed by supervisord: +- PostgreSQL instances: postgresql-14 (website), postgresql-15 (tiles), postgresql-nominatim (geocoding) +- Web services: rails (OSM website), apache2 (tile server + routing proxy) +- Tile rendering: renderd +- Routing: osrm-car, osrm-bike, osrm-foot +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..mixins import SupervisorMixin +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class MapOps(SupervisorMixin, BaseOps): + """Operations for OpenStreetMap website environment. + + Map runs multiple services managed by supervisord: + - 3 PostgreSQL instances for website, tiles, and geocoding + - Rails app for the OSM website + - Apache2 for tile serving and routing proxy + - renderd for tile rendering + - 3 OSRM instances for car/bike/foot routing + """ + + # Supervisor-managed services (env-ctrl is added dynamically via _get_expected_services if enabled) + expected_services: ClassVar[frozenset[str]] = frozenset( + { + "postgresql-14", # Website database (port 5433) + "postgresql-15", # Tile server database (port 5432) + "postgresql-nominatim", # Geocoding database (port 5434) + "rails", # OSM website (port 3000) + "apache2", # Tile server + routing proxy (port 8080) + "renderd", # Tile rendering daemon + "osrm-car", # Car routing API (port 5000) + "osrm-bike", # Bike routing API (port 5001) + "osrm-foot", # Foot routing API (port 5002) + } + ) + + cleanup_paths: ClassVar[list[str]] = [ + "/var/log/supervisor/*.log", + "/var/log/apache2/*.log", + "/app/log/*.log", + "/tmp/*", + ] + + # --- Service configuration --- + + # PostgreSQL services with their ports + PG_SERVICES: ClassVar[list[tuple[str, int]]] = [ + ("postgresql-14", 5433), # Website DB + ("postgresql-15", 5432), # Tile DB + ("postgresql-nominatim", 5434), # Geocoding DB + ] + + # OSRM routing services with their ports + OSRM_SERVICES: ClassVar[list[tuple[str, int]]] = [ + ("osrm-car", 5000), + ("osrm-bike", 5001), + ("osrm-foot", 5002), + ] + + # Test coordinates for OSRM health checks (NYC area - known to be in US Northeast dataset) + OSRM_TEST_COORDS: ClassVar[str] = "-74.006,40.7128;-73.95,40.72" + + # Tile coordinates for rendering health check (NYC area at zoom 12) + TILE_TEST_URL: ClassVar[str] = "http://localhost:8080/tile/12/1205/1539.png" + + # Nominatim test query + NOMINATIM_TEST_URL: ClassVar[str] = "http://localhost:8080/nominatim/search?q=New+York&format=json&limit=1" + + # Rails app URL + RAILS_URL: ClassVar[str] = "http://localhost:3000/" + + # --- Subclassed methods (BaseOps abstract implementations) --- + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize OpenStreetMap environment. + + The map site uses relative URLs in settings.local.yml (proxied through Apache), + so no URL configuration is needed. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL (unused - site uses relative URLs). + dry_run: If True, preview changes without applying them. + """ + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "message": "No initialization required - site uses relative URLs", + "commands_to_run": [], + "command_count": 0, + }, + exec_logs=[], + ) + return Result( + success=True, + exec_logs=[ExecLog("init", 0, "No initialization required - site uses relative URLs", "")], + ) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Start all supervisord services.""" + return cls.supervisor_start(exec_cmd) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Stop all supervisord services (except env-ctrl).""" + return cls.supervisor_stop(exec_cmd) + + @classmethod + def _get_health( + cls, exec_cmd: Optional[CommandExecutor] = None, http_url: Optional[str] = None, **kwargs: Any + ) -> Result: + """Site-specific health check for OpenStreetMap environment. + + Checks: + 1. Supervisor services are running + 2. PostgreSQL instances are ready (pg_isready) + 3. OSRM routing services are responding + 4. Tile rendering is working (apache2 + renderd) + 5. Nominatim geocoding is responding + + Args: + exec_cmd: Executor function. Defaults to subprocess. + http_url: Optional HTTP URL to check (unused, services checked internally). + + Returns: + Result with Health containing all service states. + Services use supervisor names with HEALTHY/UNHEALTHY states. + """ + all_logs: list[ExecLog] = [] + services: dict[str, ServiceState] = {} + + # 1. Check supervisor services (sets RUNNING state) + svc_result = cls._check_services(services, exec_cmd) + all_logs.extend(svc_result.exec_logs) + + # 2. Upgrade PostgreSQL to HEALTHY/UNHEALTHY via pg_isready + pg_result = cls._check_postgres_health(services, exec_cmd) + all_logs.extend(pg_result.exec_logs) + + # 3. Upgrade Rails to HEALTHY/UNHEALTHY via HTTP + rails_result = cls._check_rails_health(services, exec_cmd) + all_logs.extend(rails_result.exec_logs) + + # 4. Upgrade Apache to HEALTHY/UNHEALTHY via HTTP + apache_result = cls._check_apache_health(services, exec_cmd) + all_logs.extend(apache_result.exec_logs) + + # 5. Upgrade OSRM services to HEALTHY/UNHEALTHY + osrm_result = cls._check_osrm_health(services, exec_cmd) + all_logs.extend(osrm_result.exec_logs) + + # 6. Check tile rendering (upgrades renderd) + tile_result = cls._check_tile_health(services, exec_cmd) + all_logs.extend(tile_result.exec_logs) + + # 7. Check Nominatim geocoding + nominatim_result = cls._check_nominatim_health(services, exec_cmd) + all_logs.extend(nominatim_result.exec_logs) + + health = Health(services=services) + + return Result( + success=health.is_healthy, + value=health, + exec_logs=all_logs, + ) + + # --- Private helpers --- + + @classmethod + def _check_services(cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check supervisor services status. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + svc_result = cls.services_running(exec_cmd) + if svc_result.value: + services.update(svc_result.value) + return Result(success=svc_result.success, value=services, exec_logs=svc_result.exec_logs) + + @classmethod + def _check_rails_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade Rails from RUNNING to HEALTHY/UNHEALTHY via HTTP check. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("rails") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + + cmd = f'curl -sf --max-time 10 "{cls.RAILS_URL}"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + log = ExecLog(cmd, returncode, stdout, stderr) + + services["rails"] = ServiceState.HEALTHY if returncode == 0 else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=[log]) + + @classmethod + def _check_apache_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade Apache from RUNNING to HEALTHY/UNHEALTHY via HTTP check. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("apache2") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + + # Check Apache via the main port (proxies to Rails) + cmd = 'curl -sf --max-time 10 "http://localhost:8080/"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + log = ExecLog(cmd, returncode, stdout, stderr) + + services["apache2"] = ServiceState.HEALTHY if returncode == 0 else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=[log]) + + @classmethod + def _check_postgres_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade PostgreSQL services from RUNNING to HEALTHY/UNHEALTHY. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + all_logs: list[ExecLog] = [] + + for svc_name, port in cls.PG_SERVICES: + if services.get(svc_name) != ServiceState.RUNNING: + continue + + cmd = f"pg_isready -h localhost -p {port}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + services[svc_name] = ServiceState.HEALTHY if returncode == 0 else ServiceState.UNHEALTHY + + return Result(success=True, value=services, exec_logs=all_logs) + + @classmethod + def _check_osrm_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade OSRM services from RUNNING to HEALTHY/UNHEALTHY. + + Tests each routing service with a simple route request. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + all_logs: list[ExecLog] = [] + + for svc_name, port in cls.OSRM_SERVICES: + if services.get(svc_name) != ServiceState.RUNNING: + continue + + # Extract profile from service name (osrm-car -> car) + profile = svc_name.replace("osrm-", "") + url = f"http://localhost:{port}/route/v1/{profile}/{cls.OSRM_TEST_COORDS}" + cmd = f'curl -sf --max-time 5 "{url}"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + # Check for successful response with "code": "Ok" + is_healthy = returncode == 0 and '"code":"Ok"' in stdout.replace(" ", "") + services[svc_name] = ServiceState.HEALTHY if is_healthy else ServiceState.UNHEALTHY + + return Result(success=True, value=services, exec_logs=all_logs) + + @classmethod + def _check_tile_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Check tile rendering health (renderd). + + Fetches a known tile and verifies it's a valid PNG image. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + all_logs: list[ExecLog] = [] + + # Only check if renderd is running and apache2 is healthy (needed to serve tiles) + if services.get("renderd") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=all_logs) + if services.get("apache2") not in (ServiceState.RUNNING, ServiceState.HEALTHY): + return Result(success=True, value=services, exec_logs=all_logs) + + # Fetch tile and check HTTP status + content type + cmd = f'curl -sf --max-time 10 -o /dev/null -w "%{{http_code}}:%{{content_type}}" "{cls.TILE_TEST_URL}"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + # Check for HTTP 200 and image/png content type + is_healthy = returncode == 0 and stdout.startswith("200:") and "image/png" in stdout + services["renderd"] = ServiceState.HEALTHY if is_healthy else ServiceState.UNHEALTHY + + return Result(success=True, value=services, exec_logs=all_logs) + + @classmethod + def _check_nominatim_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Check Nominatim geocoding health. + + Performs a simple search query and verifies the response. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + all_logs: list[ExecLog] = [] + + # Nominatim depends on postgresql-nominatim and apache2 + pg_nominatim = services.get("postgresql-nominatim") + apache2 = services.get("apache2") + + # Skip if dependencies aren't healthy + if pg_nominatim not in (ServiceState.RUNNING, ServiceState.HEALTHY): + return Result(success=True, value=services, exec_logs=all_logs) + if apache2 not in (ServiceState.RUNNING, ServiceState.HEALTHY): + return Result(success=True, value=services, exec_logs=all_logs) + + cmd = f'curl -sf --max-time 10 "{cls.NOMINATIM_TEST_URL}"' + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + # Check for HTTP 200 and non-empty JSON array + is_healthy = returncode == 0 and stdout.strip().startswith("[") and len(stdout.strip()) > 2 + + # Upgrade postgresql-nominatim to HEALTHY if geocoding works + if is_healthy and pg_nominatim == ServiceState.RUNNING: + services["postgresql-nominatim"] = ServiceState.HEALTHY + + return Result(success=True, value=services, exec_logs=all_logs) diff --git a/packages/environment_control/environment_control/ops/sites/reddit.py b/packages/environment_control/environment_control/ops/sites/reddit.py new file mode 100644 index 0000000..c64a09a --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/reddit.py @@ -0,0 +1,221 @@ +"""Reddit (Postmill) environment operations. + +Discovered via exploration: +- Process manager: supervisord +- Services: nginx (port 80), php-fpm (port 9000), postgres (port 5432) +- Cleanup paths: cache, logs, node_modules, temp files +- Base URL config: Symfony .env APP_SITE_NAME and PHP/Symfony cache +- Database: PostgreSQL (user=postmill, db=postmill) +""" + +from __future__ import annotations + +import re +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..mixins import SupervisorMixin +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class RedditOps(SupervisorMixin, BaseOps): + """Operations for Reddit (Postmill) forum environment.""" + + # --- Class attributes --- + + expected_services: ClassVar[frozenset[str]] = frozenset( + { + "nginx", + "php-fpm", + "postgres", + } + ) + + cleanup_paths: ClassVar[list[str]] = [ + # Symfony application caches + "/var/www/html/var/cache/*", + "/var/www/html/var/log/*", + # Node modules (not needed at runtime) + "/var/www/html/node_modules", + # System logs + "/var/log/*.log", + "/var/log/*/*.log", + # Temp files + "/tmp/*", + ] + + # --- Site constants --- + + APP_ROOT = "/var/www/html" + POSTGRES_USER = "postmill" + POSTGRES_DB = "postmill" + + # --- Subclassed methods (BaseOps abstract implementations) --- + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize Reddit/Postmill: set site name and clear cache. + + Postmill is a Symfony application. The base URL is configured via + APP_SITE_NAME in the .env file and requires cache clear. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL for the site (e.g., "http://localhost:9999/") + dry_run: If True, preview changes without applying them. + """ + if not base_url: + raise ValueError("base_url is required") + + # Extract hostname from base_url for site name + # e.g., "http://localhost:9999/" -> "localhost:9999" + match = re.match(r"https?://([^/]+)", base_url) + site_name = match.group(1) if match else "localhost" + + commands = [ + # Update APP_SITE_NAME in .env + f'sed -i "s/^APP_SITE_NAME=.*/APP_SITE_NAME={site_name}/" {cls.APP_ROOT}/.env', + # Clear Symfony cache to pick up new config + f"rm -rf {cls.APP_ROOT}/var/cache/*", + ] + + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "base_url": base_url, + "site_name": site_name, + "commands_to_run": commands, + "command_count": len(commands), + }, + exec_logs=[], + ) + + logs: list[ExecLog] = [] + for cmd in commands: + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + logs.append(ExecLog(cmd, returncode, stdout, stderr)) + if returncode != 0: + return Result(success=False, exec_logs=logs) + + return Result(success=True, exec_logs=logs) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Start all supervisord services.""" + return cls.supervisor_start(exec_cmd) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Stop all supervisord services.""" + return cls.supervisor_stop(exec_cmd) + + @classmethod + def _get_health( + cls, exec_cmd: Optional[CommandExecutor] = None, http_url: Optional[str] = None, **kwargs: Any + ) -> Result: + """Site-specific health check for Reddit/Postmill environment. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + http_url: Optional HTTP URL to check. + + Returns: + Result with Health containing all service states. + Services use supervisor names (nginx, php-fpm, postgres). + State is HEALTHY only if process is running AND responding. + """ + all_logs: list[ExecLog] = [] + services: dict[str, ServiceState] = {} + + # Run each check and collect logs + checks = [ + cls._check_services(services, exec_cmd), + cls._check_postgres_health(services, exec_cmd), + ] + + for result in checks: + all_logs.extend(result.exec_logs) + + # Optional HTTP check + if http_url: + http_result = cls._check_http_health(services, http_url, exec_cmd) + all_logs.extend(http_result.exec_logs) + + health = Health(services=services) + + return Result( + success=health.is_healthy, + value=health, + exec_logs=all_logs, + ) + + # --- Private helpers --- + + @classmethod + def _check_services(cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check supervisor services status. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + svc_result = cls.services_running(exec_cmd) + if svc_result.value: + services.update(svc_result.value) + return Result(success=svc_result.success, value=services, exec_logs=svc_result.exec_logs) + + @classmethod + def _check_postgres(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if PostgreSQL is ready.""" + cmd = "pg_isready -h localhost" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_postgres_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade postgres from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("postgres") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_postgres(exec_cmd) + services["postgres"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_http_health( + cls, services: dict[str, ServiceState], http_url: str, exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Check HTTP endpoint and add to services dict. + + Args: + services: Dict to update with service state. + http_url: URL to check. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + result = cls._check_http(http_url, exec_cmd) + services["http"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) diff --git a/packages/environment_control/environment_control/ops/sites/shopping.py b/packages/environment_control/environment_control/ops/sites/shopping.py new file mode 100644 index 0000000..351884c --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/shopping.py @@ -0,0 +1,326 @@ +"""Shopping environment operations. + +Extends BaseOps with Magento/MySQL/Elasticsearch-specific functionality. +This is the customer-facing shopping site (as opposed to shopping_admin). +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..mixins import SupervisorMixin +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class ShoppingOps(SupervisorMixin, BaseOps): + """Operations for Shopping (Magento) environment. + + This is the customer-facing shopping site. It shares the same + Magento stack as shopping_admin but with different data and configuration. + """ + + # --- Class attributes --- + + expected_services: ClassVar[frozenset[str]] = frozenset( + { + "cron", + "elasticsearch", + "mailcatcher", + "mysqld", + "nginx", + "php-fpm", + "redis-server", + } + ) + + cleanup_paths: ClassVar[list[str]] = [ + # Elasticsearch logs + "/usr/share/java/elasticsearch/logs/*", + # Magento logs and caches + "/var/www/magento2/var/log/*", + "/var/www/magento2/var/cache/*", + "/var/www/magento2/var/page_cache/*", + "/var/www/magento2/var/session/*", + "/var/www/magento2/var/view_preprocessed/*", + "/var/www/magento2/generated/*", + "/var/www/magento2/dev/tests", + # Composer cache + "/root/.composer/cache/*", + "/var/www/.composer/cache/*", + # System package manager caches + "/var/cache/apt/archives/*", + "/var/lib/apt/lists/*", + # Service logs + "/var/log/mysql/*", + "/var/log/nginx/*", + "/var/log/*.log", + "/var/log/*/*.log", + # Temp files + "/tmp/*.log", + "/tmp/magento*", + ] + + # --- Site constants --- + + MAGENTO_ROOT = "/var/www/magento2" + ELASTICSEARCH_URL = "http://localhost:9200" + MAILCATCHER_URL = "http://localhost:88" + + # Database credentials for test/development instance only. + # DO NOT use these credentials in a production environment. + DATABASE = "magentodb" + MYSQL_USER = "magentouser" + MYSQL_PASSWORD = "MyPassword" + + # --- Subclassed methods (BaseOps abstract implementations) --- + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize Shopping site: set base URL, flush cache. + + Fails fast on first error. Retry is handled by init(). + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL for Magento (e.g., "http://localhost:7770/"). + Falls back to WA_ENV_CTRL_EXTERNAL_SITE_URL env var if not provided. + dry_run: If True, preview changes without applying them. + """ + # Get base_url from env var if not provided + if not base_url: + base_url = os.environ.get("WA_ENV_CTRL_EXTERNAL_SITE_URL", "") + if not base_url: + raise ValueError("base_url is required (set WA_ENV_CTRL_EXTERNAL_SITE_URL or pass --base-url)") + + # NOTE: SQL uses escaped double quotes to avoid quote issues with docker exec bash -c '...' + commands = [ + f'php {cls.MAGENTO_ROOT}/bin/magento setup:store-config:set --base-url="{base_url}"', + f"mysql -u {cls.MYSQL_USER} -p{cls.MYSQL_PASSWORD} {cls.DATABASE} -e " + f'"UPDATE core_config_data SET value = \\"{base_url}\\" WHERE path = \\"web/secure/base_url\\";"', + f"php {cls.MAGENTO_ROOT}/bin/magento cache:flush", + ] + + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "base_url": base_url, + "commands_to_run": commands, + "command_count": len(commands), + }, + exec_logs=[], + ) + + logs: list[ExecLog] = [] + for cmd in commands: + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + logs.append(ExecLog(cmd, returncode, stdout, stderr)) + if returncode != 0: + # Fail fast + return Result(success=False, exec_logs=logs) + + return Result(success=True, exec_logs=logs) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Start all supervisord services.""" + return cls.supervisor_start(exec_cmd) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Stop all supervisord services.""" + return cls.supervisor_stop(exec_cmd) + + @classmethod + def _get_health( + cls, exec_cmd: Optional[CommandExecutor] = None, http_url: Optional[str] = None, **kwargs: Any + ) -> Result: + """Site-specific health check for Shopping environment. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + http_url: Optional HTTP URL to check. + + Returns: + Result with Health containing all service states. + Services use supervisor names (mysqld, elasticsearch, etc.). + State is HEALTHY only if process is running AND responding. + """ + all_logs: list[ExecLog] = [] + services: dict[str, ServiceState] = {} + + # Run each check and collect logs + checks = [ + cls._check_services(services, exec_cmd), + cls._check_mysqld_health(services, exec_cmd), + cls._check_elasticsearch_health(services, exec_cmd), + cls._check_redis_health(services, exec_cmd), + cls._check_mailcatcher_health(services, exec_cmd), + ] + + for result in checks: + all_logs.extend(result.exec_logs) + + # Optional HTTP check + if http_url: + http_result = cls._check_http_health(services, http_url, exec_cmd) + all_logs.extend(http_result.exec_logs) + + health = Health(services=services) + + return Result( + success=health.is_healthy, + value=health, + exec_logs=all_logs, + ) + + # --- Private helpers --- + + @classmethod + def _check_services(cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check supervisor services status. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + svc_result = cls.services_running(exec_cmd) + if svc_result.value: + services.update(svc_result.value) + return Result(success=svc_result.success, value=services, exec_logs=svc_result.exec_logs) + + @classmethod + def _check_mysql(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if MySQL is ready.""" + cmd = f"mysqladmin ping -u {cls.MYSQL_USER} -p{cls.MYSQL_PASSWORD}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 and "alive" in stdout.lower() + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_mysqld_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade mysqld from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("mysqld") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_mysql(exec_cmd) + services["mysqld"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_elasticsearch(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if Elasticsearch is ready.""" + return cls._check_http(f"{cls.ELASTICSEARCH_URL}/_cluster/health", exec_cmd) + + @classmethod + def _check_elasticsearch_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade elasticsearch from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("elasticsearch") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_elasticsearch(exec_cmd) + services["elasticsearch"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_redis(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if Redis is ready.""" + cmd = "redis-cli ping" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 and "PONG" in stdout.upper() + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_redis_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade redis-server from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("redis-server") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_redis(exec_cmd) + services["redis-server"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_mailcatcher(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if Mailcatcher HTTP interface is ready (port 88).""" + return cls._check_http(f"{cls.MAILCATCHER_URL}/", exec_cmd) + + @classmethod + def _check_mailcatcher_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade mailcatcher from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("mailcatcher") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_mailcatcher(exec_cmd) + services["mailcatcher"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_http_health( + cls, services: dict[str, ServiceState], http_url: str, exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Check HTTP endpoint and add to services dict. + + Args: + services: Dict to update with service state. + http_url: URL to check. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + result = cls._check_http(http_url, exec_cmd) + services["http"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) diff --git a/packages/environment_control/environment_control/ops/sites/shopping_admin.py b/packages/environment_control/environment_control/ops/sites/shopping_admin.py new file mode 100644 index 0000000..26ba2d1 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/shopping_admin.py @@ -0,0 +1,365 @@ +"""Shopping Admin environment operations. + +Extends BaseOps with Magento/MySQL/Elasticsearch-specific functionality. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..mixins import SupervisorMixin +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class ShoppingAdminOps(SupervisorMixin, BaseOps): + """Operations for Shopping Admin (Magento) environment.""" + + # --- Class attributes --- + + # NOTE: elasticsearch removed - too slow under Rosetta emulation on ARM64 + expected_services: ClassVar[frozenset[str]] = frozenset( + { + "cron", + # "elasticsearch", + "mailcatcher", + "mysqld", + "nginx", + "php-fpm", + "redis-server", + } + ) + + cleanup_paths: ClassVar[list[str]] = [ + # Elasticsearch logs + "/usr/share/java/elasticsearch/logs/*", + # Magento logs and caches + "/var/www/magento2/var/log/*", + "/var/www/magento2/var/cache/*", + "/var/www/magento2/var/page_cache/*", + "/var/www/magento2/var/session/*", + "/var/www/magento2/var/view_preprocessed/*", + "/var/www/magento2/generated/*", + "/var/www/magento2/dev/tests", + # Composer cache + "/root/.composer/cache/*", + "/var/www/.composer/cache/*", + # System package manager caches + "/var/cache/apt/archives/*", + "/var/lib/apt/lists/*", + # Service logs + "/var/log/mysql/*", + "/var/log/nginx/*", + "/var/log/*.log", + "/var/log/*/*.log", + # Temp files + "/tmp/*.log", + "/tmp/magento*", + ] + + # --- Site constants --- + + MAGENTO_ROOT = "/var/www/magento2" + MAILCATCHER_URL = "http://localhost:88" + SUPERVISOR_SOCKET = "/run/supervisord.sock" + ENTRYPOINT_CMD = "/docker-entrypoint.sh supervisord -n -j /supervisord.pid" + + # Database credentials for test/development instance only. + # DO NOT use these credentials in a production environment. + DATABASE = "magentodb" + MYSQL_USER = "magentouser" + MYSQL_PASSWORD = "MyPassword" + + # --- Subclassed methods (BaseOps abstract implementations) --- + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize Shopping Admin: set base URL, disable password policies, flush cache. + + Fails fast on first error. Retry is handled by init(). + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL for Magento (e.g., "http://localhost:6680/"). + Falls back to WA_ENV_CTRL_EXTERNAL_SITE_URL env var if not provided. + dry_run: If True, preview changes without applying them. + """ + # Get base_url from env var if not provided + if not base_url: + base_url = os.environ.get("WA_ENV_CTRL_EXTERNAL_SITE_URL", "") + if not base_url: + raise ValueError("base_url is required (set WA_ENV_CTRL_EXTERNAL_SITE_URL or pass --base-url)") + + # NOTE: SQL uses escaped double quotes to avoid quote issues with docker exec bash -c '...' + commands = [ + f'php {cls.MAGENTO_ROOT}/bin/magento setup:store-config:set --base-url="{base_url}"', + f"mysql -u {cls.MYSQL_USER} -p{cls.MYSQL_PASSWORD} {cls.DATABASE} -e " + f'"UPDATE core_config_data SET value = \\"{base_url}\\" WHERE path = \\"web/secure/base_url\\";"', + f"php {cls.MAGENTO_ROOT}/bin/magento config:set admin/security/password_is_forced 0", + f"php {cls.MAGENTO_ROOT}/bin/magento config:set admin/security/password_lifetime 0", + f"php {cls.MAGENTO_ROOT}/bin/magento cache:flush", + ] + + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "base_url": base_url, + "commands_to_run": commands, + "command_count": len(commands), + }, + exec_logs=[], + ) + + logs: list[ExecLog] = [] + for cmd in commands: + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + logs.append(ExecLog(cmd, returncode, stdout, stderr)) + if returncode != 0: + # Fail fast + return Result(success=False, exec_logs=logs) + + return Result(success=True, exec_logs=logs) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Start all supervisord services. + + If supervisord is not running (e.g., container started with custom cmd), + runs the entrypoint first to start it. + """ + all_logs: list[ExecLog] = [] + + # Ensure supervisord is running (start via entrypoint if needed) + entrypoint_result = cls._run_entrypoint(exec_cmd=exec_cmd) + all_logs.extend(entrypoint_result.exec_logs) + + if not entrypoint_result.success: + return Result(success=False, exec_logs=all_logs) + + # Now start all services + start_result = cls.supervisor_start(exec_cmd) + all_logs.extend(start_result.exec_logs) + + return Result(success=start_result.success, exec_logs=all_logs) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Stop all supervisord services.""" + return cls.supervisor_stop(exec_cmd) + + @classmethod + def _run_entrypoint(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Run entrypoint to start supervisord if not already running. + + Used when container was started with a non-default command (e.g., /bin/bash) + and supervisord is not running. Waits for supervisord to become ready. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with success=True if supervisor already running or entrypoint started + and supervisord is ready. + """ + logger = logging.getLogger(__name__) + all_logs: list[ExecLog] = [] + + # Check if supervisor is already running + check_result = cls.is_supervisor_running(cls.SUPERVISOR_SOCKET, exec_cmd) + all_logs.extend(check_result.exec_logs) + + if check_result.success: + logger.debug("Supervisord already running (socket: %s)", cls.SUPERVISOR_SOCKET) + return Result(success=True, value={"already_running": True}, exec_logs=all_logs) + + # Supervisor not running - start via entrypoint in background + logger.info("Supervisord not running, starting via entrypoint...") + # Run in background since supervisord -n blocks + cmd = f"nohup {cls.ENTRYPOINT_CMD} > /dev/null 2>&1 &" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + all_logs.append(ExecLog(cmd, returncode, stdout, stderr)) + + if returncode != 0: + return Result(success=False, exec_logs=all_logs) + + # Wait for supervisord to become ready + wait_result = cls.wait_for_supervisord(exec_cmd=exec_cmd) + all_logs.extend(wait_result.exec_logs) + + return Result( + success=wait_result.success, + value={"already_running": False, "started": wait_result.success}, + exec_logs=all_logs, + ) + + @classmethod + def _get_health( + cls, exec_cmd: Optional[CommandExecutor] = None, http_url: Optional[str] = None, **kwargs: Any + ) -> Result: + """Site-specific health check for Shopping Admin environment. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + http_url: Optional HTTP URL to check. + + Returns: + Result with Health containing all service states. + Services use supervisor names (mysqld, elasticsearch, etc.). + State is HEALTHY only if process is running AND responding. + """ + all_logs: list[ExecLog] = [] + services: dict[str, ServiceState] = {} + + # Run each check and collect logs + # NOTE: ES check commented out - too slow under Rosetta emulation on ARM64 + checks = [ + cls._check_services(services, exec_cmd), + cls._check_mysqld_health(services, exec_cmd), + # cls._check_elasticsearch_health(services, exec_cmd), + cls._check_redis_health(services, exec_cmd), + cls._check_mailcatcher_health(services, exec_cmd), + ] + + for result in checks: + all_logs.extend(result.exec_logs) + + # Optional HTTP check + if http_url: + http_result = cls._check_http_health(services, http_url, exec_cmd) + all_logs.extend(http_result.exec_logs) + + health = Health(services=services) + + return Result( + success=health.is_healthy, + value=health, + exec_logs=all_logs, + ) + + # --- Private helpers --- + + @classmethod + def _check_services(cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check supervisor services status. + + Args: + services: Dict to update with service states. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + svc_result = cls.services_running(exec_cmd) + if svc_result.value: + services.update(svc_result.value) + return Result(success=svc_result.success, value=services, exec_logs=svc_result.exec_logs) + + @classmethod + def _check_mysql(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if MySQL is ready.""" + cmd = f"mysqladmin ping -u {cls.MYSQL_USER} -p{cls.MYSQL_PASSWORD}" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 and "alive" in stdout.lower() + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_mysqld_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade mysqld from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("mysqld") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_mysql(exec_cmd) + services["mysqld"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_redis(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if Redis is ready.""" + cmd = "redis-cli ping" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd) + ready = returncode == 0 and "PONG" in stdout.upper() + return Result( + success=ready, + value=ready, + exec_logs=[ExecLog(cmd, returncode, stdout, stderr)], + ) + + @classmethod + def _check_redis_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade redis-server from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("redis-server") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_redis(exec_cmd) + services["redis-server"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_mailcatcher(cls, exec_cmd: Optional[CommandExecutor] = None) -> Result: + """Check if Mailcatcher HTTP interface is ready (port 88).""" + return cls._check_http(f"{cls.MAILCATCHER_URL}/", exec_cmd) + + @classmethod + def _check_mailcatcher_health( + cls, services: dict[str, ServiceState], exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Upgrade mailcatcher from RUNNING to HEALTHY/UNHEALTHY if responding. + + Args: + services: Dict to update with service state. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + if services.get("mailcatcher") != ServiceState.RUNNING: + return Result(success=True, value=services, exec_logs=[]) + result = cls._check_mailcatcher(exec_cmd) + services["mailcatcher"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) + + @classmethod + def _check_http_health( + cls, services: dict[str, ServiceState], http_url: str, exec_cmd: Optional[CommandExecutor] = None + ) -> Result: + """Check HTTP endpoint and add to services dict. + + Args: + services: Dict to update with service state. + http_url: URL to check. + exec_cmd: Executor function. Defaults to subprocess. + + Returns: + Result with updated services dict. + """ + result = cls._check_http(http_url, exec_cmd) + services["http"] = ServiceState.HEALTHY if result.value else ServiceState.UNHEALTHY + return Result(success=True, value=services, exec_logs=result.exec_logs) diff --git a/packages/environment_control/environment_control/ops/sites/wikipedia.py b/packages/environment_control/environment_control/ops/sites/wikipedia.py new file mode 100644 index 0000000..be45fd5 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/wikipedia.py @@ -0,0 +1,88 @@ +"""Wikipedia environment operations using kiwix-serve.""" + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from ..base import BaseOps +from ..mixins import SupervisorMixin +from ..types import CommandExecutor, ExecLog, Health, Result, ServiceState + + +class WikipediaOps(SupervisorMixin, BaseOps): + """Operations for Wikipedia environment using kiwix-serve. + + Wikipedia runs on kiwix-serve managed by supervisord. + Services: kiwix-serve (HTTP server), env-ctrl (control API). + """ + + # Supervisor-managed services (env-ctrl is added dynamically via _get_expected_services if enabled) + expected_services: ClassVar[frozenset[str]] = frozenset(["kiwix-serve"]) + + # No cleanup needed - using external kiwix-serve image + cleanup_paths: ClassVar[list[str]] = [] + + @classmethod + def _init( + cls, exec_cmd: Optional[CommandExecutor] = None, base_url: str = "", dry_run: bool = False, **kwargs: Any + ) -> Result: + """Initialize Wikipedia environment. + + Kiwix-serve doesn't require any initialization - it serves the ZIM file directly. + + Args: + exec_cmd: Executor function. Defaults to subprocess. + base_url: Base URL (unused - kiwix-serve is stateless). + dry_run: If True, preview changes without applying them. + """ + if dry_run: + return Result( + success=True, + value={ + "dry_run": True, + "message": "No initialization required - kiwix-serve is stateless", + "commands_to_run": [], + "command_count": 0, + }, + exec_logs=[], + ) + return Result( + success=True, + exec_logs=[ExecLog("init", 0, "No initialization required - kiwix-serve is stateless", "")], + ) + + @classmethod + def _start(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Start kiwix-serve via supervisorctl.""" + cmd = "supervisorctl start kiwix-serve" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd=exec_cmd) + log = ExecLog(cmd, returncode, stdout, stderr) + if returncode == 0 or "already started" in stderr.lower() or "already started" in stdout.lower(): + return Result(success=True, exec_logs=[log]) + return Result(success=False, exec_logs=[log]) + + @classmethod + def _stop(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Stop kiwix-serve via supervisorctl.""" + cmd = "supervisorctl stop kiwix-serve" + returncode, stdout, stderr = cls._run_cmd(cmd, exec_cmd=exec_cmd) + log = ExecLog(cmd, returncode, stdout, stderr) + if returncode == 0 or "not running" in stderr.lower() or "not running" in stdout.lower(): + return Result(success=True, exec_logs=[log]) + return Result(success=False, exec_logs=[log]) + + @classmethod + def _get_health( + cls, exec_cmd: Optional[CommandExecutor] = None, http_url: Optional[str] = None, **kwargs: Any + ) -> Result: + """Check if all expected services are healthy via supervisorctl status.""" + result = cls.services_running(exec_cmd=exec_cmd) + + if not result.success or result.value is None: + return Result(success=False, value=Health(services={}), exec_logs=result.exec_logs) + + # Convert to Health object and check all expected services + services: dict[str, ServiceState] = result.value + health = Health(services=services) + + return Result(success=health.is_healthy, value=health, exec_logs=result.exec_logs) diff --git a/packages/environment_control/environment_control/ops/types.py b/packages/environment_control/environment_control/ops/types.py new file mode 100644 index 0000000..5b8469b --- /dev/null +++ b/packages/environment_control/environment_control/ops/types.py @@ -0,0 +1,164 @@ +"""Types for container operations. + +This module is stdlib-only and can be used both: +- Inside container: by environment_control +- Outside container: by invoke tasks (wrapping commands with docker exec) +""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, ClassVar + + +@dataclass +class ExecLog: + """Log of a single command execution.""" + + command: str + returncode: int + stdout: str + stderr: str + + @property + def success(self) -> bool: + """Return True if command exited with code 0.""" + return self.returncode == 0 + + @property + def output(self) -> str: + """Combined stdout and stderr.""" + if self.stdout and self.stderr: + return f"{self.stdout}\n{self.stderr}" + return self.stdout or self.stderr + + +class ServiceState(str, Enum): + """State of a service or component. + + Supervisor states (exact match): RUNNING, STOPPED, STARTING, BACKOFF, STOPPING, EXITED, FATAL, UNKNOWN + Custom states: HEALTHY, UNHEALTHY (for application-level health checks) + """ + + # Supervisor states (exact match) + RUNNING = "RUNNING" # Process is running (but not necessarily healthy) + STOPPED = "STOPPED" # Process is not running (expected stop) + STARTING = "STARTING" # Process is starting up + BACKOFF = "BACKOFF" # Process entered STARTING but exited too quickly + STOPPING = "STOPPING" # Process is stopping + EXITED = "EXITED" # Process exited (expected or not) + FATAL = "FATAL" # Process could not start successfully + UNKNOWN = "UNKNOWN" # Could not determine state + # Custom states (application-level) + HEALTHY = "HEALTHY" # Running AND responding correctly + UNHEALTHY = "UNHEALTHY" # Running but not responding correctly + + +@dataclass +class Health: + """Health status for an environment.""" + + services: dict[str, ServiceState] # {service_name: state} + + # States considered acceptable (service is up and working) + OK_STATES: ClassVar[frozenset[ServiceState]] = frozenset( + { + ServiceState.RUNNING, # Process running (no app-level check available) + ServiceState.HEALTHY, # Process running AND app-level check passed + } + ) + + @property + def is_healthy(self) -> bool: + """True if all services are in an acceptable state (RUNNING or HEALTHY).""" + return all(state in self.OK_STATES for state in self.services.values()) + + +@dataclass +class Result: + """Result of an operation. No Generic - use Any for value.""" + + success: bool + value: Any = None + exec_logs: list[ExecLog] = field(default_factory=list) + + def to_dict(self, max_output_len: int = 200) -> dict[str, Any]: + """Convert to dict for JSON serialization. + + Args: + max_output_len: Maximum length for stdout/stderr before truncation. + """ + # Handle Health objects + value = self.value + if isinstance(value, Health): + value = {"services": {k: v.value for k, v in value.services.items()}} + + def clean_output(text: str) -> str: + """Strip whitespace and truncate long output.""" + text = text.strip() + if len(text) > max_output_len: + return text[:max_output_len] + "... (truncated)" + return text + + # Extract message from value if it's a dict with a message key + message = "" + if isinstance(value, dict) and "message" in value: + # Copy dict to avoid mutating self.value + value = dict(value) + message = value.pop("message", "") + + return { + "success": self.success, + "message": message, + "details": { + "value": value, + "exec_logs": [ + { + "command": log.command, + "returncode": log.returncode, + "stdout": clean_output(log.stdout), + "stderr": clean_output(log.stderr), + } + for log in self.exec_logs + ], + }, + } + + +# Type alias for command executor +# Takes command string, returns (returncode, stdout, stderr) +CommandExecutor = Callable[[str], tuple[int, str, str]] + + +@dataclass(frozen=True) +class OpsConfig: + """Configuration for container operations. + + All values can be overridden via environment variables. + """ + + timeout_sec: int = 180 # WA_ENV_CTRL_CMD_TIMEOUT_SEC + retry_count: int = 2 # WA_ENV_CTRL_CMD_RETRY_COUNT + retry_delay_sec: int = 5 # WA_ENV_CTRL_RETRY_DELAY_SEC + + @classmethod + def from_env(cls) -> "OpsConfig": + """Load configuration from environment variables with defaults.""" + + def get_int(env_var: str, default: int) -> int: + val = os.environ.get(env_var) + if val is not None: + try: + return int(val) + except ValueError: + pass + return default + + return cls( + timeout_sec=get_int("WA_ENV_CTRL_CMD_TIMEOUT_SEC", cls.timeout_sec), + retry_count=get_int("WA_ENV_CTRL_CMD_RETRY_COUNT", cls.retry_count), + retry_delay_sec=get_int("WA_ENV_CTRL_RETRY_DELAY_SEC", cls.retry_delay_sec), + ) diff --git a/packages/environment_control/environment_control/py.typed b/packages/environment_control/environment_control/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/environment_control/environment_control/server/__init__.py b/packages/environment_control/environment_control/server/__init__.py new file mode 100644 index 0000000..a41cfb1 --- /dev/null +++ b/packages/environment_control/environment_control/server/__init__.py @@ -0,0 +1,7 @@ +"""Server module for environment control REST API.""" + +from __future__ import annotations + +from .app import run_server + +__all__ = ["run_server"] diff --git a/packages/environment_control/environment_control/server/app.py b/packages/environment_control/environment_control/server/app.py new file mode 100644 index 0000000..520722a --- /dev/null +++ b/packages/environment_control/environment_control/server/app.py @@ -0,0 +1,186 @@ +"""REST API server with web dashboard for environment control.""" + +from __future__ import annotations + +import html +import json +import logging +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, Optional +from urllib.parse import parse_qs, urlparse + +from .._internal.config import Config +from .._internal.logging import configure_logging +from ..ops import BaseOps, Result, get_ops_class + +logger = logging.getLogger(__name__) + +DEFAULT_PORT = 8080 +_DASHBOARD_TEMPLATE_PATH = Path(__file__).parent / "assets" / "dashboard.html" + +# Cache the dashboard template at module load time +_DASHBOARD_TEMPLATE: Optional[str] = None + + +def _get_dashboard_template() -> str: + """Get the dashboard HTML template (cached).""" + global _DASHBOARD_TEMPLATE + if _DASHBOARD_TEMPLATE is None: + _DASHBOARD_TEMPLATE = _DASHBOARD_TEMPLATE_PATH.read_text() + return _DASHBOARD_TEMPLATE + + +def _create_handler(ops: type[BaseOps], env_name: str, site_url: Optional[str] = None) -> type: # noqa: C901 + """Create a request handler class with access to the ops class. + + Args: + ops: The Ops class to use for operations. + env_name: Name of the environment for display. + site_url: URL of the actual site (for external link in dashboard). + + Returns: + A BaseHTTPRequestHandler subclass. + """ + + class EnvCtrlHandler(BaseHTTPRequestHandler): + """HTTP request handler for environment control API.""" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Override to use our logger instead of stderr.""" + logger.info("%s - %s", self.address_string(), format % args) + + def _send_json( + self, + data: dict[str, Any], + status_code: int = 200, + ) -> None: + """Send a JSON response.""" + body = json.dumps(data, indent=2).encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_html(self, html: str, status_code: int = 200) -> None: + """Send an HTML response.""" + body = html.encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _get_query_params(self) -> dict[str, str]: + """Parse query parameters from the URL.""" + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + # Return first value for each param + return {k: v[0] for k, v in params.items()} + + def _get_dashboard_html(self) -> str: + """Generate the HTML dashboard.""" + ready = ops.get_health() + + status_class = "ready" if ready.success else "not-ready" + status_text = "Ready" if ready.success else "Not Ready" + ready_message = "All services healthy" if ready.success else "Services not ready" + + template = _get_dashboard_template() + # Escape user-controlled values to prevent HTML injection + return template.format( + env_name=html.escape(env_name), + site_url=html.escape(site_url or ""), + status_class=status_class, + status_text=status_text, + ready_message=ready_message, + ) + + def do_GET(self) -> None: + """Handle GET requests.""" + parsed = urlparse(self.path) + path = parsed.path + + if path == "/": + self._send_html(self._get_dashboard_html()) + elif path == "/status": + result = ops.get_health() + self._send_json(result.to_dict()) + else: + self._send_json( + {"success": False, "error": "Not found"}, + 404, + ) + + def do_POST(self) -> None: + """Handle POST requests.""" + parsed = urlparse(self.path) + path = parsed.path + params = self._get_query_params() + wait = params.get("wait") == "1" + + result: Optional[Result] = None + + if path == "/init": + result = ops.init() + elif path == "/stop": + result = ops.stop() + elif path == "/start": + result = ops.start(wait=wait) + elif path == "/restart": + result = ops.restart(wait=wait) + else: + self._send_json( + {"success": False, "error": "Not found"}, + 404, + ) + return + + status_code = 200 if result.success else 500 + self._send_json(result.to_dict(), status_code) + + return EnvCtrlHandler + + +def run_server( + env_type: Optional[str] = None, + port: Optional[int] = None, + host: str = "0.0.0.0", + site_url: Optional[str] = None, +) -> None: + """Run the environment control server. + + Args: + env_type: Environment type to control. Defaults to WA_ENV_CTRL_TYPE env var. + port: Port to listen on. Defaults to WA_ENV_CTRL_PORT env var or 8877. + host: Host to bind to. Defaults to 0.0.0.0. + site_url: URL of the actual site. Defaults to WA_ENV_CTRL_EXTERNAL_SITE_URL env var. + """ + configure_logging() + + config = Config.from_env() + + if port is None: + port = config.port + + if site_url is None: + site_url = config.site_url + + ops = get_ops_class(env_type) + env_name = env_type or config.env_type or "unknown" + handler = _create_handler(ops, env_name, site_url) + + server = HTTPServer((host, port), handler) + logger.info("Starting server for '%s' environment on %s:%d", env_name, host, port) + print(f"Environment control server running at http://{host}:{port}") + print(f"Dashboard: http://{host}:{port}/") + print("Press Ctrl+C to stop") + + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("Server stopped by user") + print("\nServer stopped") + finally: + server.server_close() diff --git a/packages/environment_control/environment_control/server/assets/dashboard.html b/packages/environment_control/environment_control/server/assets/dashboard.html new file mode 100644 index 0000000..9d61ed3 --- /dev/null +++ b/packages/environment_control/environment_control/server/assets/dashboard.html @@ -0,0 +1,333 @@ + + + + WebArena Verified - Environment Control + + + +
+

WebArena Verified

+

Environment Control

+

Control and monitor WebArena Docker containers.

+
+ +
+
+ Environment: + {env_name} + Open Site +
+
+ Status: + {status_text} +
+
+ +
+ + + + + +
+ +
+
+ + Console Output +
+
[Ready] Waiting for commands...
+
+ + + + diff --git a/packages/environment_control/pyproject.toml b/packages/environment_control/pyproject.toml new file mode 100644 index 0000000..a070fbd --- /dev/null +++ b/packages/environment_control/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "environment_control" +version = "0.1.0" +description = "Environment control for WebArena Docker containers" +readme = "README.md" +# Python 3.9 is the minimum to support older site Docker images (e.g., GitLab) +requires-python = ">=3.9" +dependencies = [] + +[project.scripts] +env-ctrl = "environment_control.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["environment_control"] + +[tool.ruff] +extend = "../../pyproject.toml" +target-version = "py39" + +[tool.ruff.lint] +extend-ignore = [ + # Base class methods define signatures that subclasses override. + # Args like exec_cmd/kwargs are unused in base but required for interface consistency. + "ARG003", + # Disable pyupgrade - package targets Python 3.9, UP suggests 3.10+ syntax + "UP", +] + +[tool.ty.environment] +python-version = "3.9" diff --git a/packages/environment_control/uv.lock b/packages/environment_control/uv.lock new file mode 100644 index 0000000..5a79e43 --- /dev/null +++ b/packages/environment_control/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "environment-control" +version = "0.1.0" +source = { editable = "." } diff --git a/pyproject.toml b/pyproject.toml index 95263a4..602b8c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,11 +28,6 @@ dependencies = [ ] [project.optional-dependencies] -dev = [ - "click>=8.3.0", - "pydantic-settings>=2.11.0", - "litellm>=1.80.5", -] examples = [ "playwright>=1.55.0", "browser-use>=0.8.1", @@ -65,6 +60,10 @@ include = ["src/**", "assets/**"] [dependency-groups] dev = [ + "click>=8.3.0", + "environment-control", + "pydantic-settings>=2.11.0", + "litellm>=1.80.5", "invoke>=2.2.0", "semver>=3.0.0", "mkdocs>=1.6.1", @@ -76,6 +75,8 @@ dev = [ "pre-commit>=4.0.0", "pytest>=8.4.2", "pytest-cov>=6.0.0", + "pytest-playwright>=0.7.0", + "pytest-rerunfailures>=15.0", "rich>=14.2.0", "ruff>=0.14.1", "twine>=6.0.1", @@ -85,6 +86,9 @@ dev = [ [tool.uv] default-groups = "all" +[tool.uv.sources] +environment-control = { path = "packages/environment_control", editable = true } + [tool.ty.environment] python-version = "3.11" @@ -197,6 +201,9 @@ convention = "google" "D101", # settings classes don't need docstrings "PLR0913", # CLI tasks naturally have many arguments (flags) ] +"packages/**/*.py" = [ + "ANN401", # Any type allowed for kwargs and generic functions +] "**/patches/**/*.py" = [ "E501", # embedded JavaScript code has long lines ] diff --git a/src/webarena_verified/__main__.py b/src/webarena_verified/__main__.py index 44d4be0..88ab4d3 100644 --- a/src/webarena_verified/__main__.py +++ b/src/webarena_verified/__main__.py @@ -24,6 +24,7 @@ setup_webarena_verified_logging, ) from webarena_verified.core.utils.trim_network_logs import trim_har_file +from webarena_verified.environments.env_ctrl_client import EnvCtrlClient from webarena_verified.types.agent_response import MainObjectiveType from webarena_verified.types.config import WebArenaVerifiedConfig from webarena_verified.types.eval import EvalStatus, TaskEvalResult, TasksEvalResults, TransformedAgentResponse @@ -35,8 +36,54 @@ ) +def _add_env_subcommand(subparsers: argparse._SubParsersAction) -> None: + """Add the env subcommand group for Docker container control.""" + env_parser = subparsers.add_parser( + "env", + help="Control Docker container environments", + description="Control and monitor Docker container environments via the environment control REST API", + epilog=textwrap.dedent(""" + examples: + # Check environment status + webarena-verified env status --url http://localhost:8877 + + # Start environment (wait for ready) + webarena-verified env start --url http://localhost:8877 --wait + + # Stop environment + webarena-verified env stop --url http://localhost:8877 + + # Initialize environment + webarena-verified env init --url http://localhost:8877 + """), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + env_subparsers = env_parser.add_subparsers(dest="env_command", required=True) + + # env status subcommand + env_status_parser = env_subparsers.add_parser("status", help="Get environment status") + env_status_parser.add_argument("--url", type=str, required=True, help="Environment control server URL") + env_status_parser.add_argument("--timeout", type=int, default=30, help="Request timeout in seconds") + + # env start subcommand + env_start_parser = env_subparsers.add_parser("start", help="Start the environment") + env_start_parser.add_argument("--url", type=str, required=True, help="Environment control server URL") + env_start_parser.add_argument("--wait", action="store_true", help="Wait until environment is ready") + env_start_parser.add_argument("--timeout", type=int, default=30, help="Request timeout in seconds") + + # env stop subcommand + env_stop_parser = env_subparsers.add_parser("stop", help="Stop the environment") + env_stop_parser.add_argument("--url", type=str, required=True, help="Environment control server URL") + env_stop_parser.add_argument("--timeout", type=int, default=30, help="Request timeout in seconds") + + # env init subcommand + env_init_parser = env_subparsers.add_parser("init", help="Initialize the environment") + env_init_parser.add_argument("--url", type=str, required=True, help="Environment control server URL") + env_init_parser.add_argument("--timeout", type=int, default=30, help="Request timeout in seconds") + + def create_parser() -> argparse.ArgumentParser: - """Create the argument parser for the CLI""" + """Create the argument parser for the CLI.""" parser = argparse.ArgumentParser( prog="webarena-verified", description="WebArena Verified CLI for running and evaluating tasks", @@ -331,6 +378,9 @@ def create_parser() -> argparse.ArgumentParser: trim_logs_parser.add_argument("--input", type=str, required=True, help="Input HAR file path") trim_logs_parser.add_argument("--output", type=str, required=True, help="Output HAR file path") + # env subcommand group - control Docker container environments + _add_env_subcommand(subparsers) + return parser @@ -1163,9 +1213,38 @@ def trim_network_logs(args: argparse.Namespace) -> int: return 1 +def env_command(args: argparse.Namespace) -> int | None: + """Execute env subcommands (status, start, stop, init).""" + client = EnvCtrlClient(base_url=args.url, timeout=args.timeout) + + try: + if args.env_command == "status": + result = client.status() + elif args.env_command == "start": + result = client.start(wait=args.wait) + elif args.env_command == "stop": + result = client.stop() + elif args.env_command == "init": + result = client.init() + else: + logger.error(f"Unknown env command: {args.env_command}") + return 1 + + # Print result as JSON + print(json.dumps(result, indent=2)) + + return 0 if result.get("success") else 1 + + except ConnectionError as e: + logger.error(f"Connection error: {e}") + return 1 + except RuntimeError as e: + logger.error(f"Server error: {e}") + return 1 + + def create_submission_pkg(args: argparse.Namespace) -> int: """Execute create-submission-pkg command""" - # Display command info command_info = { "Command": "create-submission-pkg", @@ -1281,6 +1360,8 @@ def main() -> None: sys.exit(dataset_get(args)) elif args.command == "agent-input-get": sys.exit(agent_input_get(args)) + elif args.command == "env": + sys.exit(env_command(args)) else: parser.print_help() sys.exit(1) diff --git a/src/webarena_verified/environments/env_ctrl_client/__init__.py b/src/webarena_verified/environments/env_ctrl_client/__init__.py new file mode 100644 index 0000000..d31682e --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/__init__.py @@ -0,0 +1,22 @@ +"""Environment control client package. + +Provides HTTP and Docker exec clients for interacting with environment control +servers running inside Docker containers. +""" + +from webarena_verified.types.environment import EnvCtrlResult + +from .base import EnvCtrlClientProtocol +from .docker_client import EnvCtrlDockerClient +from .http_client import HttpClient + +# Alias for backwards compatibility +EnvCtrlClient = HttpClient + +__all__ = [ + "EnvCtrlClient", + "EnvCtrlClientProtocol", + "EnvCtrlDockerClient", + "EnvCtrlResult", + "HttpClient", +] diff --git a/src/webarena_verified/environments/env_ctrl_client/base.py b/src/webarena_verified/environments/env_ctrl_client/base.py new file mode 100644 index 0000000..1752324 --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/base.py @@ -0,0 +1,74 @@ +"""Base protocol for environment control clients.""" + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class EnvCtrlClientProtocol(Protocol): + """Protocol defining the interface for environment control clients. + + Both HTTP and Docker exec clients implement this interface, allowing + callers to use either interchangeably. + """ + + def init(self) -> dict[str, Any]: + """Initialize the environment. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def status(self) -> dict[str, Any]: + """Get environment status. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def start(self, wait: bool = False) -> dict[str, Any]: + """Start the environment. + + Args: + wait: If True, wait until environment is ready. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def stop(self) -> dict[str, Any]: + """Stop the environment. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def restart(self, wait: bool = False) -> dict[str, Any]: + """Restart the environment. + + Args: + wait: If True, wait until environment is ready after restart. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def wait_until_ready( + self, + timeout: float = 60.0, + interval: float = 1.0, + ) -> dict[str, Any]: + """Poll until the environment is ready or timeout is reached. + + Args: + timeout: Maximum time to wait in seconds. + interval: Time between polls in seconds. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... diff --git a/src/webarena_verified/environments/env_ctrl_client/docker_client.py b/src/webarena_verified/environments/env_ctrl_client/docker_client.py new file mode 100644 index 0000000..9c6ca8c --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/docker_client.py @@ -0,0 +1,375 @@ +"""Docker exec client for the environment control CLI. + +This module provides a client that executes env-ctrl CLI commands via +docker exec, allowing control of environments without requiring network access. +""" + +import json +import subprocess +import time +from pathlib import Path + +from webarena_verified.types.environment import EnvCtrlResult + + +class EnvCtrlDockerClient: + """Client that executes env-ctrl CLI commands via docker exec. + + Args: + container_name: Name of the Docker container to exec into. + timeout: Command timeout in seconds. Defaults to 30. + """ + + def __init__( + self, + container_name: str, + timeout: int = 30, + ) -> None: + self._container_name = container_name + self._timeout = timeout + + @classmethod + def create( + cls, + container_name: str, + timeout: int = 30, + ) -> "EnvCtrlDockerClient": + """Create a new client instance. + + Args: + container_name: Name of the Docker container to exec into. + timeout: Command timeout in seconds. Defaults to 30. + + Returns: + New EnvCtrlDockerClient instance. + """ + return cls(container_name, timeout) + + def run_command(self, *args: str) -> EnvCtrlResult: + """Run arbitrary env-ctrl command with pass-through args. + + This method allows running any env-ctrl command with any arguments, + providing full CLI pass-through capability. + + Args: + *args: Command and arguments to pass to env-ctrl. + Example: ("--verbose", "status") or ("start", "--wait", "--timeout", "120") + + Returns: + EnvCtrlResult with success, message, and details. + + Raises: + ConnectionError: If unable to connect to the Docker container. + RuntimeError: If the command fails or returns invalid output. + + Example: + >>> client = EnvCtrlDockerClient("shopping_admin") + >>> client.run_command("--verbose", "status") + EnvCtrlResult(success=True, message='Environment is running', details={...}) + """ + cmd = [ + "docker", + "exec", + self._container_name, + "env-ctrl", + *args, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self._timeout, + ) + except subprocess.TimeoutExpired: + return EnvCtrlResult( + success=False, + message=f"Command timed out after {self._timeout}s", + details={"timeout": self._timeout, "command": " ".join(cmd)}, + ) + except FileNotFoundError as e: + raise ConnectionError("Docker command not found. Is Docker installed?") from e + except Exception as e: + raise RuntimeError(f"Failed to execute docker command: {e}") from e + + # Try to parse JSON output (from --verbose flag) + output = result.stdout.strip() + if output: + try: + parsed = json.loads(output) + return EnvCtrlResult.model_validate(parsed) + except json.JSONDecodeError: + pass + + # Fall back to constructing response from exit code and output + if result.returncode == 0: + return EnvCtrlResult( + success=True, + message=output or "Command completed successfully", + details={}, + ) + error_output = result.stderr.strip() or result.stdout.strip() + return EnvCtrlResult( + success=False, + message=error_output or f"Command failed with exit code {result.returncode}", + details={"exit_code": result.returncode}, + ) + + def _run_cli(self, command: str, *extra_args: str) -> EnvCtrlResult: + """Run an env-ctrl command with --verbose flag for JSON output. + + Args: + command: The env-ctrl subcommand (e.g., "status", "start"). + *extra_args: Additional arguments to pass to the command. + + Returns: + EnvCtrlResult with success, message, and details. + """ + return self.run_command("--verbose", command, *extra_args) + + def _run_setup_cli(self, op: str, *extra_args: str) -> EnvCtrlResult: + """Run an env-ctrl command with --verbose flag for setup operations. + + Args: + op: The operation (e.g., "init", "patch", "cleanup"). + *extra_args: Additional arguments to pass to the command. + + Returns: + EnvCtrlResult with success, message, and details. + """ + return self.run_command("--verbose", op, *extra_args) + + def init(self, base_url: str | None = None) -> EnvCtrlResult: + """Initialize the environment. + + Args: + base_url: Base URL for the environment (e.g., "http://localhost:8080/"). + + Returns: + EnvCtrlResult with success, message, and details. + """ + if base_url is not None: + return self._run_setup_cli("init", "--base-url", base_url) + return self._run_setup_cli("init") + + def status(self) -> EnvCtrlResult: + """Get environment status. + + Returns: + EnvCtrlResult with success, message, and details. + """ + return self._run_cli("status") + + def start(self, wait: bool = False, timeout: int | None = None) -> EnvCtrlResult: + """Start the environment. + + Args: + wait: If True, wait until environment is ready. + timeout: Timeout in seconds for waiting (only used with wait=True). + + Returns: + EnvCtrlResult with success, message, and details. + """ + args = [] + if wait: + args.append("--wait") + if timeout is not None: + args.extend(["--timeout", str(timeout)]) + return self._run_cli("start", *args) + + def stop(self) -> EnvCtrlResult: + """Stop the environment. + + Returns: + EnvCtrlResult with success, message, and details. + """ + return self._run_cli("stop") + + def restart(self, wait: bool = False, timeout: int | None = None) -> EnvCtrlResult: + """Restart the environment. + + Args: + wait: If True, wait until environment is ready. + timeout: Timeout in seconds for waiting (only used with wait=True). + + Returns: + EnvCtrlResult with success, message, and details. + """ + args = [] + if wait: + args.append("--wait") + if timeout is not None: + args.extend(["--timeout", str(timeout)]) + return self._run_cli("restart", *args) + + def wait_until_ready( + self, + timeout: float = 60.0, + interval: float = 1.0, + ) -> EnvCtrlResult: + """Poll until the environment is ready or timeout is reached. + + Args: + timeout: Maximum time to wait in seconds. + interval: Time between polls in seconds. + + Returns: + EnvCtrlResult with success, message, and details. + """ + start_time = time.time() + last_result = EnvCtrlResult( + success=False, + message="Timeout waiting for environment", + details={}, + ) + + while time.time() - start_time < timeout: + try: + result = self.status() + if result.success: + return result + last_result = result + except (ConnectionError, RuntimeError) as e: + last_result = EnvCtrlResult( + success=False, + message=str(e), + details={}, + ) + + time.sleep(interval) + + elapsed = time.time() - start_time + return EnvCtrlResult( + success=False, + message=f"Timeout after {elapsed:.1f}s waiting for environment", + details={ + "last_result": last_result.model_dump(), + "timeout": timeout, + "elapsed": elapsed, + }, + ) + + def patch(self) -> EnvCtrlResult: + """Apply patches from /tmp/patches/. + + Returns: + EnvCtrlResult with success, message, and details. + """ + return self._run_setup_cli("patch") + + def stage_files(self, source_dir: str, target_dir: str = "/tmp/patches") -> EnvCtrlResult: + """Stage files from host directory to container. + + Creates a tarball of source_dir and pipes it to env-ctrl setup stage. + + Args: + source_dir: Local directory containing files to stage. + target_dir: Target directory inside container (default: /tmp/patches). + + Returns: + EnvCtrlResult with success, message, and details. + """ + source = Path(source_dir) + if not source.is_dir(): + return EnvCtrlResult( + success=False, + message=f"Source directory does not exist: {source_dir}", + details={}, + ) + + # Create tar and pipe to env-ctrl setup stage + tar_cmd = ["tar", "-C", str(source), "-cf", "-", "."] + stage_cmd = [ + "docker", + "exec", + "-i", + self._container_name, + "env-ctrl", + "setup", + "stage", + "--target", + target_dir, + ] + + try: + tar_proc = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE) + try: + result = subprocess.run( + stage_cmd, + stdin=tar_proc.stdout, + capture_output=True, + text=True, + timeout=self._timeout, + ) + finally: + # Close the pipe in parent to avoid resource leaks + if tar_proc.stdout: + tar_proc.stdout.close() + tar_proc.wait() + + # Check tar exit code + if tar_proc.returncode != 0: + return EnvCtrlResult( + success=False, + message=f"tar failed with exit code {tar_proc.returncode}", + details={"source": source_dir}, + ) + + if result.returncode == 0: + try: + parsed = json.loads(result.stdout.strip()) + return EnvCtrlResult.model_validate(parsed) + except json.JSONDecodeError: + return EnvCtrlResult( + success=True, + message=f"Staged files to {target_dir}", + details={"source": source_dir, "target": target_dir}, + ) + else: + return EnvCtrlResult( + success=False, + message=result.stderr or f"Stage failed with exit code {result.returncode}", + details={"exit_code": result.returncode}, + ) + except subprocess.TimeoutExpired: + return EnvCtrlResult( + success=False, + message=f"Stage timed out after {self._timeout}s", + details={}, + ) + + def analyze_disk(self, top: int = 20) -> EnvCtrlResult: + """Analyze disk usage in container. + + Args: + top: Number of largest entries to return. + + Returns: + EnvCtrlResult with success, message, and details containing disk usage output. + """ + cmd = [ + "docker", + "exec", + self._container_name, + "sh", + "-c", + f"du -ah / 2>/dev/null | sort -rh | head -n {top}", + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=self._timeout) + return EnvCtrlResult( + success=True, + message="Disk analysis complete", + details={ + "output": result.stdout, + "top": top, + }, + ) + except subprocess.TimeoutExpired: + return EnvCtrlResult( + success=False, + message=f"Analysis timed out after {self._timeout}s", + details={}, + ) diff --git a/src/webarena_verified/environments/env_ctrl_client/http_client.py b/src/webarena_verified/environments/env_ctrl_client/http_client.py new file mode 100644 index 0000000..668c36b --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/http_client.py @@ -0,0 +1,169 @@ +"""HTTP client for the environment control REST API. + +This module provides a stdlib-only HTTP client for interacting with the +environment control server running inside Docker containers. +""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +class HttpClient: + """HTTP client for interacting with the environment control REST API. + + Args: + base_url: Base URL of the server (e.g., "http://localhost:8080"). + Defaults to http://localhost:8080. + timeout: Request timeout in seconds. Defaults to 30. + """ + + def __init__( + self, + base_url: str = "http://localhost:8080", + timeout: int = 30, + ) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout + + def _request( + self, + method: str, + endpoint: str, + params: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Make an HTTP request to the server. + + Args: + method: HTTP method (GET or POST). + endpoint: API endpoint (e.g., "/status"). + params: Optional query parameters. + + Returns: + Parsed JSON response as a dict. + + Raises: + ConnectionError: If unable to connect to the server. + RuntimeError: If the server returns an error response. + """ + url = f"{self._base_url}{endpoint}" + + if params: + query = urllib.parse.urlencode(params) + url = f"{url}?{query}" + + req = urllib.request.Request(url, method=method) + req.add_header("Accept", "application/json") + + try: + with urllib.request.urlopen(req, timeout=self._timeout) as response: + data = response.read().decode("utf-8") + return json.loads(data) + except urllib.error.HTTPError as e: + try: + error_body = e.read().decode("utf-8") + return json.loads(error_body) + except Exception: + raise RuntimeError(f"Server error: HTTP {e.code}") from e + except urllib.error.URLError as e: + raise ConnectionError(f"Cannot connect to server: {e.reason}") from e + except json.JSONDecodeError as e: + raise RuntimeError(f"Invalid JSON response: {e}") from e + + def init(self) -> dict[str, Any]: + """Initialize the environment. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + return self._request("POST", "/init") + + def status(self) -> dict[str, Any]: + """Get environment status. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + return self._request("GET", "/status") + + def start(self, wait: bool = False) -> dict[str, Any]: + """Start the environment. + + Args: + wait: If True, wait until environment is ready. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + params = {"wait": "1"} if wait else None + return self._request("POST", "/start", params=params) + + def stop(self) -> dict[str, Any]: + """Stop the environment. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + return self._request("POST", "/stop") + + def restart(self, wait: bool = False) -> dict[str, Any]: + """Restart the environment. + + Args: + wait: If True, wait until environment is ready. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + params = {"wait": "1"} if wait else None + return self._request("POST", "/restart", params=params) + + def wait_until_ready( + self, + timeout: float = 60.0, + interval: float = 1.0, + ) -> dict[str, Any]: + """Poll until the environment is ready or timeout is reached. + + Args: + timeout: Maximum time to wait in seconds. + interval: Time between polls in seconds. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + start_time = time.time() + last_result: dict[str, Any] = { + "success": False, + "message": "Timeout waiting for environment", + "details": {}, + } + + while time.time() - start_time < timeout: + try: + result = self.status() + if result.get("success"): + return result + last_result = result + except (ConnectionError, RuntimeError) as e: + last_result = { + "success": False, + "message": str(e), + "details": {}, + } + + time.sleep(interval) + + elapsed = time.time() - start_time + return { + "success": False, + "message": f"Timeout after {elapsed:.1f}s waiting for environment", + "details": { + "last_result": last_result, + "timeout": timeout, + "elapsed": elapsed, + }, + } diff --git a/src/webarena_verified/types/__init__.py b/src/webarena_verified/types/__init__.py index 9dc85d1..2cf01e2 100644 --- a/src/webarena_verified/types/__init__.py +++ b/src/webarena_verified/types/__init__.py @@ -1,6 +1,7 @@ """Type definitions for WebArena Verified.""" from .agent_response import FinalAgentResponse, MainObjectiveType, Status +from .environment import EnvCtrlResult from .task import ( AgentResponseEvaluatorCfg, EvaluatorCfg, @@ -11,6 +12,7 @@ __all__ = [ "AgentResponseEvaluatorCfg", + "EnvCtrlResult", "EvaluatorCfg", "FinalAgentResponse", "MainObjectiveType", diff --git a/src/webarena_verified/types/environment.py b/src/webarena_verified/types/environment.py index 0bc0472..74d8c32 100644 --- a/src/webarena_verified/types/environment.py +++ b/src/webarena_verified/types/environment.py @@ -1,5 +1,7 @@ """Environment-related type definitions for WebArena Verified.""" +from typing import Any + from pydantic import BaseModel, Field @@ -22,6 +24,21 @@ def success(self) -> bool: return self.returncode == 0 +class EnvCtrlResult(BaseModel): + """Result from env-ctrl CLI commands. + + Attributes: + success: Whether the command succeeded. + message: Human-readable message describing the result. + details: Additional details (varies by command). + """ + + success: bool = Field(description="Whether the command succeeded") + message: str = Field(default="", description="Human-readable message describing the result") + details: dict[str, Any] = Field(default_factory=dict, description="Additional details (varies by command)") + + __all__ = [ + "EnvCtrlResult", "SiteInstanceCommandResult", ] diff --git a/tasks.py b/tasks.py index edf808f..73db83c 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ from invoke import Collection -from dev import tasks as contrib +from dev import tasks as dev_tasks ns = Collection() -ns.add_collection(Collection.from_module(contrib), name="contrib") +ns.add_collection(Collection.from_module(dev_tasks), name="dev") diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 821f398..1e6fe9b 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -12,15 +12,6 @@ def webarena_verified_docker_img(request): return request.config.getoption("--webarena-verified-docker-img") -@pytest.fixture -def docker(): - """Check that docker is available and return the CLI name.""" - docker_path = shutil.which("docker") - if docker_path is None: - raise RuntimeError("'docker' is missing or not available in PATH.") - return "docker" - - @pytest.fixture def uvx(): """Check that uvx is available and return the CLI name.""" diff --git a/tests/conftest.py b/tests/conftest.py index 2124d62..57e94b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,23 @@ """Pytest configuration for all tests.""" +import shutil + +import pytest + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line("markers", "docker: marks tests that require Docker") + + +@pytest.fixture(scope="session") +def docker(): + """Check that docker is available and return the CLI name.""" + docker_path = shutil.which("docker") + if docker_path is None: + raise RuntimeError("'docker' is missing or not available in PATH.") + return "docker" + def pytest_addoption(parser): """Add custom CLI options.""" diff --git a/tests/integration/environment_control/conftest.py b/tests/integration/environment_control/conftest.py new file mode 100644 index 0000000..d695c83 --- /dev/null +++ b/tests/integration/environment_control/conftest.py @@ -0,0 +1,169 @@ +"""Fixtures for environment_control integration tests. + +Starts a Python 3.10 container with the environment_control package mounted +and installed, running the env-ctrl server with the dummy ops. +""" + +import socket +import subprocess +import time +from collections.abc import Generator +from dataclasses import dataclass + +import pytest + +from webarena_verified.environments.env_ctrl_client import EnvCtrlClient + +# Container configuration +CONTAINER_NAME = "env-ctrl-test" +CONTAINER_IMAGE = "python:3.10-slim" + + +@dataclass +class EnvControlContainer: + """Container info returned by env_control_container fixture.""" + + name: str + base_url: str + port: int + + +def _find_free_port() -> int: + """Find a free port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def _image_exists(image: str) -> bool: + """Check if a Docker image exists locally.""" + result = subprocess.run( + ["docker", "images", "-q", image], + capture_output=True, + text=True, + ) + return bool(result.stdout.strip()) + + +@pytest.fixture(scope="session") +def env_ctrl_server_port() -> int: + """Find an available port for the env-ctrl server.""" + return _find_free_port() + + +@pytest.fixture(scope="module") +def env_control_container(request, docker, env_ctrl_server_port) -> Generator[EnvControlContainer]: + """Start a Python container with environment_control installed and server running. + + The container: + - Uses python:3.10-slim image + - Mounts packages/environment_control into /app + - Installs the package with pip + - Runs env-ctrl serve with WA_ENV_CTRL_TYPE=dummy + + Yields: + EnvControlContainer with name, base_url, and port. + + Cleanup: + - Removes the container + - If the image was pulled by this test (didn't exist before), removes it too + """ + port = env_ctrl_server_port + base_url = f"http://localhost:{port}" + + # Check if image existed before we start + image_existed_before = _image_exists(CONTAINER_IMAGE) + + # Get the path to packages/environment_control using pytest rootpath + package_path = request.config.rootpath / "packages" / "environment_control" + + # Stop any existing container + subprocess.run( + ["docker", "rm", "-f", CONTAINER_NAME], + capture_output=True, + ) + + # Start the container + result = subprocess.run( + [ + "docker", + "run", + "-d", + "--name", + CONTAINER_NAME, + "-p", + f"{port}:{port}", + "-v", + f"{package_path}:/app", + "-w", + "/app", + "-e", + "WA_ENV_CTRL_TYPE=dummy", + "-e", + f"WA_ENV_CTRL_PORT={port}", + CONTAINER_IMAGE, + "bash", + "-c", + "pip install --upgrade pip && pip install . && env-ctrl serve", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to start container: {result.stderr}") + + # Wait for server to be ready + client = EnvCtrlClient(base_url=base_url, timeout=5) + max_wait = 30 + start_time = time.time() + + while time.time() - start_time < max_wait: + try: + client.status() + break + except Exception: + time.sleep(1) + else: + # Get container logs for debugging + logs = subprocess.run( + ["docker", "logs", CONTAINER_NAME], + capture_output=True, + text=True, + ) + subprocess.run(["docker", "rm", "-f", CONTAINER_NAME], capture_output=True) + raise RuntimeError(f"Server did not start within {max_wait}s. Logs:\n{logs.stdout}\n{logs.stderr}") + + yield EnvControlContainer(name=CONTAINER_NAME, base_url=base_url, port=port) + + # Cleanup container + subprocess.run(["docker", "rm", "-f", CONTAINER_NAME], capture_output=True) + + # If image didn't exist before, remove it + if not image_existed_before: + subprocess.run(["docker", "rmi", CONTAINER_IMAGE], capture_output=True) + + +@pytest.fixture +def client(env_control_container: EnvControlContainer) -> EnvCtrlClient: + """Create a client connected to the test container's server.""" + return EnvCtrlClient(base_url=env_control_container.base_url, timeout=10) + + +@pytest.fixture +def docker_exec(env_control_container: EnvControlContainer): + """Return a function to execute commands in the container.""" + container_name = env_control_container.name + + def _exec(cmd: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + docker_cmd = ["docker", "exec"] + + if env: + for key, value in env.items(): + docker_cmd.extend(["-e", f"{key}={value}"]) + + docker_cmd.extend([container_name, "bash", "-c", cmd]) + + return subprocess.run(docker_cmd, capture_output=True, text=True) + + return _exec diff --git a/tests/integration/environment_control/test_cli.py b/tests/integration/environment_control/test_cli.py new file mode 100644 index 0000000..974ea85 --- /dev/null +++ b/tests/integration/environment_control/test_cli.py @@ -0,0 +1,124 @@ +"""Integration tests for environment_control CLI. + +Tests the env-ctrl CLI commands running in a Docker container. + +Usage: + pytest tests/integration/environment_control/test_cli.py -v +""" + +import json + +import pytest + +pytestmark = pytest.mark.docker + +# --- CLI Command Tests --- + + +def test_cli_list(docker_exec): + """Test 'env-ctrl list' shows available environment types.""" + result = docker_exec("env-ctrl list") + + assert result.returncode == 0 + assert "dummy" in result.stdout + assert "admin" in result.stdout + + +def test_cli_status(docker_exec): + """Test 'env-ctrl status' with dummy env.""" + result = docker_exec("env-ctrl -e dummy status") + + assert result.returncode == 0 + assert "OK" in result.stdout + + +def test_cli_status_verbose(docker_exec): + """Test 'env-ctrl status -v' returns JSON.""" + result = docker_exec("env-ctrl -v -e dummy status") + + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["success"] is True + assert "details" in data + assert "value" in data["details"] + + +def test_cli_start(docker_exec): + """Test 'env-ctrl start' with dummy env.""" + result = docker_exec("env-ctrl -e dummy start") + + assert result.returncode == 0 + assert "OK" in result.stdout + + +def test_cli_stop(docker_exec): + """Test 'env-ctrl stop' with dummy env.""" + result = docker_exec("env-ctrl -e dummy stop") + + assert result.returncode == 0 + assert "OK" in result.stdout + + +# --- CLI with Environment Variable Control --- + + +def test_cli_status_unhealthy(docker_exec): + """Test 'env-ctrl status' returns failure when DUMMY_HEALTHY=0.""" + result = docker_exec("env-ctrl -e dummy status", env={"DUMMY_HEALTHY": "0"}) + + assert result.returncode == 1 + assert "FAILED" in result.stdout + + +def test_cli_status_unhealthy_verbose(docker_exec): + """Test 'env-ctrl status -v' returns failure JSON when DUMMY_HEALTHY=0.""" + result = docker_exec("env-ctrl -v -e dummy status", env={"DUMMY_HEALTHY": "0"}) + + assert result.returncode == 1 + data = json.loads(result.stdout) + assert data["success"] is False + + +def test_cli_init_failure(docker_exec): + """Test 'env-ctrl init' fails when DUMMY_INIT_FAIL=1.""" + # Note: init requires base_url for shopping_admin, but dummy doesn't need it + result = docker_exec("env-ctrl -e dummy init", env={"DUMMY_INIT_FAIL": "1"}) + + assert result.returncode == 1 + assert "FAILED" in result.stdout + + +def test_cli_start_failure(docker_exec): + """Test 'env-ctrl start' fails when DUMMY_START_FAIL=1.""" + result = docker_exec("env-ctrl -e dummy start", env={"DUMMY_START_FAIL": "1"}) + + assert result.returncode == 1 + assert "FAILED" in result.stdout + + +def test_cli_stop_failure(docker_exec): + """Test 'env-ctrl stop' fails when DUMMY_STOP_FAIL=1.""" + result = docker_exec("env-ctrl -e dummy stop", env={"DUMMY_STOP_FAIL": "1"}) + + assert result.returncode == 1 + assert "FAILED" in result.stdout + + +# --- CLI Error Handling --- + + +def test_cli_unknown_env_type(docker_exec): + """Test 'env-ctrl' with unknown environment type.""" + result = docker_exec("env-ctrl -e unknown status") + + assert result.returncode == 1 + assert "Unknown environment type" in result.stderr or "unknown" in result.stderr.lower() + + +def test_cli_missing_env_type(docker_exec): + """Test 'env-ctrl' without WA_ENV_CTRL_TYPE set returns error.""" + # Override the container's WA_ENV_CTRL_TYPE + result = docker_exec("env-ctrl status", env={"WA_ENV_CTRL_TYPE": ""}) + + # Should fail because no env type specified + assert result.returncode == 1 diff --git a/tests/integration/environment_control/test_dashboard.py b/tests/integration/environment_control/test_dashboard.py new file mode 100644 index 0000000..62be92e --- /dev/null +++ b/tests/integration/environment_control/test_dashboard.py @@ -0,0 +1,96 @@ +"""Integration tests for the environment_control dashboard. + +Tests the dashboard endpoint and interactive behavior using Playwright. + +Usage: + pytest tests/integration/environment_control/test_dashboard.py -v + +Requirements for interactive tests: + pip install pytest-playwright + playwright install chromium +""" + +import urllib.request + +import pytest +from playwright.sync_api import Page, expect + +pytestmark = pytest.mark.docker + + +def test_dashboard_returns_html(env_control_container): + """Test GET / returns HTML dashboard.""" + url = f"{env_control_container.base_url}/" + + with urllib.request.urlopen(url, timeout=5) as response: + content_type = response.headers.get("Content-Type", "") + content = response.read().decode("utf-8") + + assert response.status == 200 + assert "text/html" in content_type + assert "" in content + assert "Environment Control" in content + + +# --- Playwright Interactive Tests --- + + +def test_dashboard_elements(env_control_container, page: Page): + """Test dashboard has correct title, header, and buttons.""" + page.goto(f"{env_control_container.base_url}/") + + expect(page).to_have_title("WebArena Verified - Environment Control") + expect(page.locator("h1")).to_have_text("WebArena Verified") + expect(page.locator("h2")).to_have_text("Environment Control") + + # Check environment name and status badge + expect(page.locator(".info-bar")).to_contain_text("dummy") + expect(page.locator(".status-badge")).to_be_visible() + + # Check all buttons exist + expect(page.locator("button.btn-init")).to_have_text("Initialize") + expect(page.locator("button.btn-start")).to_have_text("Start") + expect(page.locator("button.btn-stop")).to_have_text("Stop") + expect(page.locator("button.btn-restart")).to_have_text("Restart") + expect(page.locator("button.btn-refresh")).to_have_text("Refresh Status") + + +def test_dashboard_button_actions(env_control_container, page: Page): + """Test clicking buttons updates console output.""" + page.goto(f"{env_control_container.base_url}/") + console = page.locator("#console") + + # Initial state + expect(console).to_contain_text("Waiting for commands") + + # Test init button + page.click("button.btn-init") + page.wait_for_timeout(1000) + expect(console).to_contain_text("INIT") + expect(console).to_contain_text("success") + + # Test start button + page.click("button.btn-start") + page.wait_for_timeout(1000) + expect(console).to_contain_text("START") + + # Test stop button + page.click("button.btn-stop") + page.wait_for_timeout(1000) + expect(console).to_contain_text("STOP") + + +def test_dashboard_refresh_status(env_control_container, page: Page): + """Test refresh button updates status badge.""" + page.goto(f"{env_control_container.base_url}/") + + page.click("button.btn-refresh") + page.wait_for_timeout(1000) + + console = page.locator("#console") + expect(console).to_contain_text("REFRESH STATUS") + + status_badge = page.locator(".status-badge") + expect(status_badge).to_be_visible() + text = status_badge.text_content() + assert text in ("Ready", "Not Ready") diff --git a/tests/integration/environment_control/test_server.py b/tests/integration/environment_control/test_server.py new file mode 100644 index 0000000..6694851 --- /dev/null +++ b/tests/integration/environment_control/test_server.py @@ -0,0 +1,87 @@ +"""Integration tests for environment_control server. + +Tests the HTTP server running in a Docker container with dummy ops. + +Usage: + pytest tests/integration/environment_control/test_server.py -v +""" + +import json +import urllib.error +import urllib.request + +import pytest + +pytestmark = pytest.mark.docker + +# --- Server Endpoint Tests --- + + +def test_status_endpoint(client): + """Test GET /status returns health info.""" + result = client.status() + + assert "success" in result + assert "details" in result + assert "value" in result["details"] + assert "exec_logs" in result["details"] + assert result["success"] is True + + +def test_start_endpoint(client): + """Test POST /start.""" + result = client.start() + + assert result["success"] is True + + +def test_start_endpoint_with_wait(client): + """Test POST /start?wait=1.""" + result = client.start(wait=True) + + assert result["success"] is True + + +def test_stop_endpoint(client): + """Test POST /stop.""" + result = client.stop() + + assert result["success"] is True + + +def test_init_endpoint(client): + """Test POST /init.""" + result = client.init() + + assert result["success"] is True + + +# --- Client Utility Tests --- + + +def test_wait_until_ready(client): + """Test wait_until_ready returns expected structure.""" + result = client.wait_until_ready(timeout=10, interval=0.5) + + # Result should have success key and message + assert "success" in result + assert "message" in result + # If successful, details should contain value info + if result["success"]: + assert "details" in result + + +# --- Error Response Tests --- + + +def test_not_found_endpoint(env_control_container): + """Test 404 response for unknown endpoints.""" + url = f"{env_control_container.base_url}/unknown" + req = urllib.request.Request(url) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req, timeout=5) + + assert exc_info.value.code == 404 + error_data = json.loads(exc_info.value.read().decode("utf-8")) + assert error_data["success"] is False diff --git a/uv.lock b/uv.lock index 61e8e6f..cf425f9 100644 --- a/uv.lock +++ b/uv.lock @@ -725,6 +725,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "environment-control" +version = "0.1.0" +source = { editable = "packages/environment_control" } + [[package]] name = "fastuuid" version = "0.14.0" @@ -5715,6 +5720,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -5729,6 +5747,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-playwright" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + [[package]] name = "python-crfsuite" version = "0.9.11" @@ -5807,6 +5853,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pytimeparse2" version = "1.7.1" @@ -6457,6 +6515,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "thefuzz" version = "0.22.1" @@ -6870,11 +6937,6 @@ dependencies = [ ] [package.optional-dependencies] -dev = [ - { name = "click" }, - { name = "litellm" }, - { name = "pydantic-settings" }, -] examples = [ { name = "aiohttp" }, { name = "browser-use" }, @@ -6886,7 +6948,10 @@ examples = [ [package.dev-dependencies] dev = [ + { name = "click" }, + { name = "environment-control" }, { name = "invoke" }, + { name = "litellm" }, { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -6894,8 +6959,11 @@ dev = [ { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, { name = "pre-commit" }, + { name = "pydantic-settings" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-playwright" }, + { name = "pytest-rerunfailures" }, { name = "rich" }, { name = "ruff" }, { name = "semver" }, @@ -6909,19 +6977,16 @@ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "browser-use", marker = "extra == 'examples'", specifier = ">=0.8.1" }, { name = "browser-use", marker = "extra == 'examples'", specifier = ">=0.9.6" }, - { name = "click", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "compact-json", specifier = "==1.8.1" }, { name = "filelock", marker = "extra == 'examples'", specifier = ">=3.16.1" }, { name = "geographiclib", specifier = "==2.1" }, { name = "geopy", specifier = "==2.4.1" }, { name = "jinja2", marker = "extra == 'examples'", specifier = ">=3.1.6" }, { name = "jsonpath-ng", specifier = "==1.7.0" }, - { name = "litellm", marker = "extra == 'dev'", specifier = ">=1.80.5" }, { name = "pint", specifier = "==0.25" }, { name = "playwright", marker = "extra == 'examples'", specifier = ">=1.55.0" }, { name = "price-parser", specifier = "==0.5.0" }, { name = "pydantic", specifier = "==2.12.0" }, - { name = "pydantic-settings", marker = "extra == 'dev'", specifier = ">=2.11.0" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "pytimeparse2", specifier = "==1.7.1" }, { name = "requests", marker = "extra == 'examples'", specifier = ">=2.32.0" }, @@ -6933,11 +6998,14 @@ requires-dist = [ { name = "usaddress-scourgify", specifier = "==0.6.0" }, { name = "word2number", specifier = "==1.1" }, ] -provides-extras = ["dev", "examples"] +provides-extras = ["examples"] [package.metadata.requires-dev] dev = [ + { name = "click", specifier = ">=8.3.0" }, + { name = "environment-control", editable = "packages/environment_control" }, { name = "invoke", specifier = ">=2.2.0" }, + { name = "litellm", specifier = ">=1.80.5" }, { name = "mike", specifier = ">=2.1.0" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-material", specifier = ">=9.6.21" }, @@ -6945,8 +7013,11 @@ dev = [ { name = "mkdocstrings", specifier = ">=0.30.1" }, { name = "mkdocstrings-python", specifier = ">=1.12.2" }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-playwright", specifier = ">=0.7.0" }, + { name = "pytest-rerunfailures", specifier = ">=15.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = ">=0.14.1" }, { name = "semver", specifier = ">=3.0.0" },