Files
foxhunt/testing/e2e/src/services.rs
jgrusewski 075f715fa1 refactor: replace all api_gateway/web-gateway references across codebase
- Rename test functions, variables, bench names (api_gateway → api)
- Update e2e orchestrator executable path (target/debug/api)
- Clean Grafana dashboards: remove dead web-gateway panels, fix trailing
  pipes/commas, update pod selectors
- Update metrics_validation test metric prefixes (api_gateway_ → api_)
- Fix .gitlab-ci.yml remaining path reference
- Fix FXT CLI test flag and function names
- Keep JWT issuer foxhunt-api-gateway (cross-service auth compat)
- Keep serde alias api_gateway_url (config backwards compat)

cargo check --workspace passes cleanly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:18:58 +01:00

459 lines
14 KiB
Rust

//! Service Management for E2E Testing
//!
//! Provides orchestration capabilities for starting, stopping, and managing
//! all services required for end-to-end testing of the Foxhunt system.
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, error, info, warn};
/// Service type enumeration for orchestrator
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ServiceType {
ApiGateway,
TradingService,
BacktestingService,
MLTrainingService,
Database,
}
impl ServiceType {
pub fn as_str(&self) -> &str {
match self {
ServiceType::ApiGateway => "api",
ServiceType::TradingService => "trading",
ServiceType::BacktestingService => "backtesting",
ServiceType::MLTrainingService => "ml_training",
ServiceType::Database => "database",
}
}
}
/// Service configuration for orchestrator
#[derive(Debug, Clone)]
pub struct ServiceConfig {
pub service_type: ServiceType,
pub executable_path: String,
pub port: u16,
pub health_endpoint: String,
pub startup_timeout: Duration,
pub environment: HashMap<String, String>,
pub working_directory: PathBuf,
pub log_file: Option<String>,
}
/// Legacy service configuration (kept for backward compatibility)
#[derive(Debug, Clone)]
pub struct LegacyServiceConfig {
pub name: String,
pub binary_name: String,
pub port: u16,
pub args: Vec<String>,
pub env_vars: HashMap<String, String>,
pub startup_timeout: Duration,
}
/// Service manager for orchestrating all Foxhunt services
#[derive(Debug)]
pub struct ServiceManager {
services: HashMap<String, LegacyServiceConfig>,
processes: HashMap<String, Child>,
base_path: std::path::PathBuf,
}
impl Default for ServiceManager {
fn default() -> Self {
Self::new()
}
}
impl ServiceManager {
/// Create a new service manager
pub fn new() -> Self {
Self {
services: HashMap::new(),
processes: HashMap::new(),
base_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
/// Create a new service manager with legacy configs
pub fn new_with_configs() -> Result<Self> {
let base_path = std::env::current_dir().context("Failed to get current directory")?;
let services = Self::create_service_configs();
Ok(Self {
services,
processes: HashMap::new(),
base_path,
})
}
/// Start a service using orchestrator ServiceConfig
pub async fn start_service(&mut self, config: ServiceConfig) -> Result<()> {
tracing::info!("Starting service: {:?}", config.service_type);
// For now, just track that we tried to start it
// Full implementation would spawn the actual service process
Ok(())
}
/// Start all services
pub async fn start_all_services(&mut self) -> Result<()> {
info!("🚀 Starting all services for E2E testing");
// Start services in dependency order
let service_order = vec!["trading_service", "backtesting_service"];
for service_name in service_order {
if let Some(config) = self.services.get(service_name) {
self.start_legacy_service(config.clone())
.await
.with_context(|| format!("Failed to start service: {}", service_name))?;
} else {
warn!("Service configuration not found: {}", service_name);
}
}
info!("✅ All services started successfully");
Ok(())
}
/// Stop all services
pub async fn stop_all_services(&mut self) -> Result<()> {
info!("🛑 Stopping all services");
// Stop in reverse order
let service_order = vec!["backtesting_service", "trading_service"];
for service_name in service_order {
if let Some(mut process) = self.processes.remove(service_name) {
info!("Stopping {}", service_name);
match process.kill() {
Ok(_) => {
info!("✅ {} stopped", service_name);
},
Err(e) => {
warn!("Failed to kill {}: {}", service_name, e);
},
}
// Wait for process to exit
match process.wait() {
Ok(_) => debug!("{} process exited", service_name),
Err(e) => warn!("Error waiting for {} to exit: {}", service_name, e),
}
}
}
info!("✅ All services stopped");
Ok(())
}
/// Start a specific service (legacy)
async fn start_legacy_service(&mut self, config: LegacyServiceConfig) -> Result<()> {
info!("🔧 Starting service: {}", config.name);
// Check if binary exists
let binary_path = self
.base_path
.join("target/release")
.join(&config.binary_name);
if !binary_path.exists() {
// Try debug build
let debug_binary_path = self
.base_path
.join("target/debug")
.join(&config.binary_name);
if !debug_binary_path.exists() {
return Err(anyhow::anyhow!(
"Service binary not found: {} (tried both release and debug)",
config.binary_name
));
}
}
// Build command
let mut command = Command::new("cargo");
command
.arg("run")
.arg("--bin")
.arg(&config.binary_name)
.current_dir(&self.base_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Add arguments
for arg in &config.args {
command.arg(arg);
}
// Set environment variables
for (key, value) in &config.env_vars {
command.env(key, value);
}
// Set common environment variables
command
.env("RUST_LOG", "info")
.env("FOXHUNT_TEST_MODE", "true")
.env(
"DATABASE_URL",
std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://localhost/foxhunt_test".to_string()),
);
debug!("Starting command: {:?}", command);
// Start the process
let child = command
.spawn()
.with_context(|| format!("Failed to spawn {}", config.name))?;
self.processes.insert(config.name.clone(), child);
// Wait for service to be ready
info!(
"⏳ Waiting for {} to be ready on port {}...",
config.name, config.port
);
self.wait_for_legacy_service_ready(&config)
.await
.with_context(|| format!("Service {} failed to become ready", config.name))?;
info!("✅ Service {} started successfully", config.name);
Ok(())
}
/// Wait for a service to be ready
async fn wait_for_legacy_service_ready(&self, config: &LegacyServiceConfig) -> Result<()> {
use tokio::net::TcpStream;
let timeout = config.startup_timeout;
let check_interval = Duration::from_millis(500);
let start_time = std::time::Instant::now();
while start_time.elapsed() < timeout {
match TcpStream::connect(("127.0.0.1", config.port)).await {
Ok(_) => {
debug!("Service {} is ready on port {}", config.name, config.port);
return Ok(());
},
Err(_) => {
debug!("Service {} not ready yet, retrying...", config.name);
sleep(check_interval).await;
},
}
}
Err(anyhow::anyhow!(
"Service {} failed to become ready within {:?}",
config.name,
timeout
))
}
/// Create service configurations
fn create_service_configs() -> HashMap<String, LegacyServiceConfig> {
let mut services = HashMap::new();
// Trading Service
services.insert(
"trading_service".to_string(),
LegacyServiceConfig {
name: "trading_service".to_string(),
binary_name: "trading_service".to_string(),
port: 50051,
args: vec![],
env_vars: HashMap::new(),
startup_timeout: Duration::from_secs(30),
},
);
// Backtesting Service
services.insert(
"backtesting_service".to_string(),
LegacyServiceConfig {
name: "backtesting_service".to_string(),
binary_name: "backtesting_service".to_string(),
port: 50052,
args: vec![],
env_vars: HashMap::new(),
startup_timeout: Duration::from_secs(30),
},
);
services
}
/// Check if a service is running
pub fn is_service_running(&self, service_name: &str) -> bool {
self.processes.contains_key(service_name)
}
/// Get service status
pub async fn get_service_status(&self, service_name: &str) -> ServiceStatus {
if let Some(config) = self.services.get(service_name) {
// Check if process is running
let process_running = self.is_service_running(service_name);
// Check if port is accessible
let port_accessible =
(tokio::net::TcpStream::connect(("127.0.0.1", config.port)).await).is_ok();
if process_running && port_accessible {
ServiceStatus::Running
} else if process_running {
ServiceStatus::Starting
} else {
ServiceStatus::Stopped
}
} else {
ServiceStatus::NotFound
}
}
/// Get all service statuses
pub async fn get_all_service_statuses(&self) -> HashMap<String, ServiceStatus> {
let mut statuses = HashMap::new();
for service_name in self.services.keys() {
let status = self.get_service_status(service_name).await;
statuses.insert(service_name.clone(), status);
}
statuses
}
}
/// Service status enumeration
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Running,
Starting,
Stopped,
NotFound,
}
impl std::fmt::Display for ServiceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServiceStatus::Running => write!(f, "Running"),
ServiceStatus::Starting => write!(f, "Starting"),
ServiceStatus::Stopped => write!(f, "Stopped"),
ServiceStatus::NotFound => write!(f, "Not Found"),
}
}
}
/// Cleanup implementation for ServiceManager
impl Drop for ServiceManager {
fn drop(&mut self) {
// Try to stop all services on drop
for (service_name, mut process) in self.processes.drain() {
info!("Cleaning up service: {}", service_name);
if let Err(e) = process.kill() {
error!("Failed to kill service {}: {}", service_name, e);
}
}
}
}
/// Helper function to check if all required binaries exist
pub fn check_service_binaries() -> Result<Vec<String>> {
let base_path = std::env::current_dir().context("Failed to get current directory")?;
let required_binaries = vec!["trading_service", "backtesting_service"];
let mut missing_binaries = Vec::new();
for binary in &required_binaries {
let release_path = base_path.join("target/release").join(binary);
let debug_path = base_path.join("target/debug").join(binary);
if !release_path.exists() && !debug_path.exists() {
missing_binaries.push(binary.to_string());
}
}
Ok(missing_binaries)
}
/// Build all required service binaries
pub async fn build_service_binaries() -> Result<()> {
info!("🔨 Building service binaries for E2E testing");
let binaries = vec!["trading_service", "backtesting_service"];
for binary in &binaries {
info!("Building {}", binary);
let output = tokio::process::Command::new("cargo")
.arg("build")
.arg("--bin")
.arg(binary)
.arg("--release")
.output()
.await
.with_context(|| format!("Failed to build {}", binary))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to build {}: {}", binary, stderr));
}
info!("✅ Built {}", binary);
}
info!("✅ All service binaries built successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_config_creation() {
let services = ServiceManager::create_service_configs();
assert!(services.contains_key("trading_service"));
assert!(services.contains_key("backtesting_service"));
let trading_config = services.get("trading_service").unwrap();
assert_eq!(trading_config.port, 50051);
assert_eq!(trading_config.binary_name, "trading_service");
}
#[test]
fn test_service_status_display() {
assert_eq!(ServiceStatus::Running.to_string(), "Running");
assert_eq!(ServiceStatus::Starting.to_string(), "Starting");
assert_eq!(ServiceStatus::Stopped.to_string(), "Stopped");
assert_eq!(ServiceStatus::NotFound.to_string(), "Not Found");
}
#[tokio::test]
async fn test_service_manager_creation() {
// ServiceManager::new() returns Self directly, not Result
let manager = ServiceManager::new();
// Check that the manager has been initialized (empty services is expected for new())
assert_eq!(manager.services.len(), 0);
// Test with configs
let manager_with_configs = ServiceManager::new_with_configs();
assert!(manager_with_configs.is_ok());
let manager = manager_with_configs.unwrap();
assert!(manager.services.len() >= 2);
}
}