- 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>
459 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|