From a60ce489cd29cd00ae54090bd78dc86a0306eada Mon Sep 17 00:00:00 2001 From: Amine El Hattami Date: Mon, 2 Feb 2026 12:47:09 +0100 Subject: [PATCH 01/15] Add environment control package for WebArena Docker containers Introduces a new package for managing WebArena environment containers with: - CLI interface (env-ctrl) for status checks, reset, and server operations - REST API server with dashboard for remote environment control - Site-specific operations for gitlab, reddit, shopping, shopping_admin, and wikipedia - Docker and HTTP client implementations for the main webarena_verified package - Integration tests for CLI, server, and dashboard functionality Co-Authored-By: Claude Opus 4.5 --- packages/environment_control/README.md | 34 ++ .../environment_control/__init__.py | 33 ++ .../environment_control/__main__.py | 14 + .../environment_control/_internal/__init__.py | 8 + .../environment_control/_internal/config.py | 31 ++ .../environment_control/_internal/logging.py | 101 ++++ .../environment_control/cli.py | 233 +++++++++ .../environment_control/env-ctrl | 5 + .../environment_control/ops/__init__.py | 91 ++++ .../environment_control/ops/base.py | 484 ++++++++++++++++++ .../ops/mixins/__init__.py | 12 + .../ops/mixins/supervisor.py | 233 +++++++++ .../environment_control/ops/sites/__init__.py | 13 + .../environment_control/ops/sites/dummy.py | 119 +++++ .../environment_control/ops/sites/gitlab.py | 364 +++++++++++++ .../environment_control/ops/sites/map.py | 380 ++++++++++++++ .../environment_control/ops/sites/reddit.py | 221 ++++++++ .../environment_control/ops/sites/shopping.py | 326 ++++++++++++ .../ops/sites/shopping_admin.py | 365 +++++++++++++ .../ops/sites/wikipedia.py | 88 ++++ .../environment_control/ops/types.py | 162 ++++++ .../environment_control/py.typed | 0 .../environment_control/server/__init__.py | 7 + .../environment_control/server/app.py | 182 +++++++ .../server/assets/dashboard.html | 332 ++++++++++++ packages/environment_control/pyproject.toml | 33 ++ packages/environment_control/uv.lock | 8 + pyproject.toml | 9 + src/webarena_verified/__main__.py | 78 ++- .../environments/env_ctrl_client/__init__.py | 22 + .../environments/env_ctrl_client/base.py | 114 +++++ .../env_ctrl_client/docker_client.py | 345 +++++++++++++ .../env_ctrl_client/http_client.py | 157 ++++++ src/webarena_verified/types/__init__.py | 2 + src/webarena_verified/types/environment.py | 17 + .../environment_control/conftest.py | 140 +++++ .../environment_control/test_cli.py | 135 +++++ .../environment_control/test_dashboard.py | 339 ++++++++++++ .../environment_control/test_server.py | 79 +++ uv.lock | 73 +++ 40 files changed, 5388 insertions(+), 1 deletion(-) create mode 100644 packages/environment_control/README.md create mode 100644 packages/environment_control/environment_control/__init__.py create mode 100644 packages/environment_control/environment_control/__main__.py create mode 100644 packages/environment_control/environment_control/_internal/__init__.py create mode 100644 packages/environment_control/environment_control/_internal/config.py create mode 100644 packages/environment_control/environment_control/_internal/logging.py create mode 100644 packages/environment_control/environment_control/cli.py create mode 100755 packages/environment_control/environment_control/env-ctrl create mode 100644 packages/environment_control/environment_control/ops/__init__.py create mode 100644 packages/environment_control/environment_control/ops/base.py create mode 100644 packages/environment_control/environment_control/ops/mixins/__init__.py create mode 100644 packages/environment_control/environment_control/ops/mixins/supervisor.py create mode 100644 packages/environment_control/environment_control/ops/sites/__init__.py create mode 100644 packages/environment_control/environment_control/ops/sites/dummy.py create mode 100644 packages/environment_control/environment_control/ops/sites/gitlab.py create mode 100644 packages/environment_control/environment_control/ops/sites/map.py create mode 100644 packages/environment_control/environment_control/ops/sites/reddit.py create mode 100644 packages/environment_control/environment_control/ops/sites/shopping.py create mode 100644 packages/environment_control/environment_control/ops/sites/shopping_admin.py create mode 100644 packages/environment_control/environment_control/ops/sites/wikipedia.py create mode 100644 packages/environment_control/environment_control/ops/types.py create mode 100644 packages/environment_control/environment_control/py.typed create mode 100644 packages/environment_control/environment_control/server/__init__.py create mode 100644 packages/environment_control/environment_control/server/app.py create mode 100644 packages/environment_control/environment_control/server/assets/dashboard.html create mode 100644 packages/environment_control/pyproject.toml create mode 100644 packages/environment_control/uv.lock create mode 100644 src/webarena_verified/environments/env_ctrl_client/__init__.py create mode 100644 src/webarena_verified/environments/env_ctrl_client/base.py create mode 100644 src/webarena_verified/environments/env_ctrl_client/docker_client.py create mode 100644 src/webarena_verified/environments/env_ctrl_client/http_client.py create mode 100644 tests/integration/environment_control/conftest.py create mode 100644 tests/integration/environment_control/test_cli.py create mode 100644 tests/integration/environment_control/test_dashboard.py create mode 100644 tests/integration/environment_control/test_server.py diff --git a/packages/environment_control/README.md b/packages/environment_control/README.md new file mode 100644 index 0000000..47d2c38 --- /dev/null +++ b/packages/environment_control/README.md @@ -0,0 +1,34 @@ +# environment_control + +Environment control package for WebArena Docker containers. + +## Installation + +```bash +pip install . +``` + +## Usage + +### CLI + +```bash +# List available environment types +env-ctrl list + +# Check status +ENV_TYPE=dummy env-ctrl status + +# Start server +ENV_TYPE=dummy env-ctrl serve --port 8877 +``` + +### Python + +```python +from environment_control import get_ops_class + +ops = get_ops_class("dummy") +result = ops.get_health() +print(result.success) +``` 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..cc5247a --- /dev/null +++ b/packages/environment_control/environment_control/cli.py @@ -0,0 +1,233 @@ +"""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_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) + + # 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/env-ctrl b/packages/environment_control/environment_control/env-ctrl new file mode 100755 index 0000000..b9e1242 --- /dev/null +++ b/packages/environment_control/environment_control/env-ctrl @@ -0,0 +1,5 @@ +#!/bin/sh +# env-ctrl wrapper (for local development) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export PYTHONPATH="${SCRIPT_DIR%/*}" +exec python3 -m environment_control.cli "$@" 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..739c9ce --- /dev/null +++ b/packages/environment_control/environment_control/ops/mixins/supervisor.py @@ -0,0 +1,233 @@ +"""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..091529d --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/dummy.py @@ -0,0 +1,119 @@ +"""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"}) + + # Track running state (class-level for simplicity) + _running: ClassVar[bool] = True + + @classmethod + def _get_health(cls, exec_cmd: Optional[CommandExecutor] = None, **kwargs: Any) -> Result: + """Return health based on DUMMY_HEALTHY env var and running state.""" + # If stopped, return unhealthy + if not cls._running: + services = { + "service-a": ServiceState.STOPPED, + "service-b": ServiceState.STOPPED, + "service-c": ServiceState.STOPPED, + } + return Result( + success=False, + value=Health(services=services), + exec_logs=[ExecLog("dummy-health-check", 1, "services stopped", "")], + ) + + 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" + + if not fail: + cls._running = True + + 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" + + if not fail: + cls._running = False + + 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..579f782 --- /dev/null +++ b/packages/environment_control/environment_control/ops/sites/gitlab.py @@ -0,0 +1,364 @@ +"""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 + +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. + """ + import os + + 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..6153349 --- /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..702996a --- /dev/null +++ b/packages/environment_control/environment_control/ops/types.py @@ -0,0 +1,162 @@ +"""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: + 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..2994fef --- /dev/null +++ b/packages/environment_control/environment_control/server/app.py @@ -0,0 +1,182 @@ +"""REST API server with web dashboard for environment control.""" + +from __future__ import annotations + +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() + return template.format( + env_name=env_name, + site_url=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) + 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..98751e1 --- /dev/null +++ b/packages/environment_control/environment_control/server/assets/dashboard.html @@ -0,0 +1,332 @@ + + + + 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..767b0d1 --- /dev/null +++ b/packages/environment_control/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "environment_control" +version = "0.1.0" +description = "Environment control for WebArena Docker containers" +readme = "README.md" +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..dff5d5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "click>=8.3.0", + "environment-control", "pydantic-settings>=2.11.0", "litellm>=1.80.5", ] @@ -76,6 +77,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 +88,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 +203,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..d6cb330 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 @@ -331,6 +332,50 @@ 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 + 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") + return parser @@ -1163,9 +1208,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 +1355,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..e09be4c --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/base.py @@ -0,0 +1,114 @@ +"""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 is_ready(self) -> dict[str, Any]: + """Check if environment is ready. + + 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'. + """ + ... + + def cleanup(self) -> dict[str, Any]: + """Run cleanup to remove logs, caches, temp files. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def patch(self) -> dict[str, Any]: + """Apply patches from staging directory. + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def optimize(self) -> dict[str, Any]: + """Optimize assets (compress images, etc.). + + Returns: + Dict with 'success', 'message', and 'details'. + """ + ... + + def config(self) -> dict[str, Any]: + """Get build config (commit_env, cleanup_paths, etc.). + + Returns: + Dict with build configuration. + """ + ... 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..baff5e4 --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/docker_client.py @@ -0,0 +1,345 @@ +"""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 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) + result = subprocess.run( + stage_cmd, + stdin=tar_proc.stdout, + capture_output=True, + text=True, + timeout=self._timeout, + ) + tar_proc.wait() + + 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..9fa23ae --- /dev/null +++ b/src/webarena_verified/environments/env_ctrl_client/http_client.py @@ -0,0 +1,157 @@ +"""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 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/tests/integration/environment_control/conftest.py b/tests/integration/environment_control/conftest.py new file mode 100644 index 0000000..1857e8f --- /dev/null +++ b/tests/integration/environment_control/conftest.py @@ -0,0 +1,140 @@ +"""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 os +import subprocess +import time + +import pytest + +from webarena_verified.environments.env_ctrl_client import EnvCtrlClient + +# Container configuration +CONTAINER_NAME = "env-ctrl-test" +CONTAINER_IMAGE = "python:3.10-slim" +SERVER_PORT = 8877 + + +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="module") +def env_control_container(request): + """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 + - Exposes port 8877 + + Cleanup: + - Removes the container + - If the image was pulled by this test (didn't exist before), removes it too + """ + # Check if image existed before we start + image_existed_before = _image_exists(CONTAINER_IMAGE) + + # Get the path to packages/environment_control + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + package_path = os.path.join(repo_root, "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"{SERVER_PORT}:{SERVER_PORT}", + "-v", + f"{package_path}:/app", + "-w", + "/app", + "-e", + "WA_ENV_CTRL_TYPE=dummy", + "-e", + "WA_ENV_CTRL_PORT=8877", + 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=f"http://localhost:{SERVER_PORT}", 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, + ) + raise RuntimeError(f"Server did not start within {max_wait}s. Logs:\n{logs.stdout}\n{logs.stderr}") + + yield CONTAINER_NAME + + # 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): + """Create a client connected to the test container's server.""" + return EnvCtrlClient(base_url=f"http://localhost:{SERVER_PORT}", timeout=10) + + +@pytest.fixture +def docker_exec(env_control_container): + """Return a function to execute commands in the container.""" + + 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..871d5fc --- /dev/null +++ b/tests/integration/environment_control/test_cli.py @@ -0,0 +1,135 @@ +"""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 + +# --- 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 "value" in data + + +def test_cli_is_ready(docker_exec): + """Test 'env-ctrl is_ready' with dummy env.""" + result = docker_exec("env-ctrl -e dummy is_ready") + + assert result.returncode == 0 + assert "OK" in result.stdout + + +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 + + +def test_cli_restart(docker_exec): + """Test 'env-ctrl restart' with dummy env.""" + result = docker_exec("env-ctrl -e dummy restart") + + 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..73765ce --- /dev/null +++ b/tests/integration/environment_control/test_dashboard.py @@ -0,0 +1,339 @@ +"""Integration tests for the environment_control dashboard. + +Tests both HTML content and interactive functionality using Playwright. + +Usage: + pytest tests/integration/environment_control/test_dashboard.py -v + +Requirements for Playwright tests: + pip install pytest-playwright + playwright install chromium +""" + +import urllib.request + +import pytest + +# --- HTML Content Tests --- + + +def test_dashboard_returns_html(env_control_container): + """Test GET / returns HTML with correct content type.""" + url = "http://localhost:8877/" + 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 + + +def test_dashboard_shows_environment_name(env_control_container): + """Test dashboard displays the environment name.""" + url = "http://localhost:8877/" + with urllib.request.urlopen(url, timeout=5) as response: + content = response.read().decode("utf-8") + + # Should show "dummy" as the environment name + assert "dummy" in content + assert "Environment:" in content + + +def test_dashboard_shows_status_badge(env_control_container): + """Test dashboard displays status badge.""" + url = "http://localhost:8877/" + with urllib.request.urlopen(url, timeout=5) as response: + content = response.read().decode("utf-8") + + # Should have status badge with ready/not-ready class + assert "status-badge" in content + assert "Status:" in content + + +def test_dashboard_has_action_buttons(env_control_container): + """Test dashboard has all action buttons.""" + url = "http://localhost:8877/" + with urllib.request.urlopen(url, timeout=5) as response: + content = response.read().decode("utf-8") + + # Check for all action buttons + assert "Initialize" in content + assert "Start" in content + assert "Stop" in content + assert "Restart" in content + assert "Refresh Status" in content + + +def test_dashboard_has_console_area(env_control_container): + """Test dashboard has console output area.""" + url = "http://localhost:8877/" + with urllib.request.urlopen(url, timeout=5) as response: + content = response.read().decode("utf-8") + + assert "Console Output" in content + assert 'id="console"' in content + + +def test_dashboard_has_javascript(env_control_container): + """Test dashboard includes required JavaScript functions.""" + url = "http://localhost:8877/" + with urllib.request.urlopen(url, timeout=5) as response: + content = response.read().decode("utf-8") + + # Check for key JavaScript functions + assert "doAction" in content + assert "refreshStatus" in content + assert "