Implement comprehensive Runpod deployment with S3 volume mount architecture for FP32 ML model training on Tesla V100 GPUs. ## Infrastructure Components ### Deployment Scripts (scripts/) - runpod_deploy.sh: Master deployment orchestrator (8-step workflow) - runpod_upload.sh: S3 upload for binaries and test data - upload_env_to_runpod.sh: Secure .env credentials upload - runpod_deploy_test.sh: Prerequisites validation ### Docker Configuration - Dockerfile.runpod: Multi-stage CUDA 12.1 runtime (~2GB, no binaries) - entrypoint.sh: Volume verification and training execution - Architecture: Volume mount (NO S3 downloads in pods) ### S3 Configuration - Bucket: se3zdnb5o4 (Iceland region: eur-is-1) - Endpoint: https://s3api-eur-is-1.runpod.io - Structure: binaries/, test_data/, models/, .env ### OpenTofu Infrastructure (terraform/runpod/) - main.tf: Pod and volume resources - variables.tf: Configuration variables - outputs.tf: Pod connection info - Security: NO credentials in state (uses volume .env) ## Deployment Assets Uploaded ### Training Binaries (77MB) - train_tft_parquet (23M) - TFT-225 features - train_mamba2_parquet (22M) - MAMBA-2 state space - train_dqn (22M) - Deep Q-Network - train_ppo (13M) - Proximal Policy Optimization ### Test Data (13.8 MB) - 9 Parquet files: ES.FUT, NQ.FUT, 6E.FUT, ZN.FUT (180-day datasets) ### Credentials - .env file (1.5 KB, private access, chmod 600) ## Documentation ### Deployment Guides - RUNPOD_DEPLOYMENT_READY_SUMMARY.md: Complete deployment status - RUNPOD_VOLUME_DEPLOYMENT_GUIDE.md: Step-by-step guide (42KB) - RUNPOD_DEPLOYMENT_QUICK_START.md: Quick reference - RUNPOD_UPLOAD_GUIDE.md: S3 upload instructions - RUNPOD_VOLUME_CONFIGURATION_COMPLETE.md: S3 setup report - RUNPOD_S3_PARQUET_UPLOAD_REPORT.md: Data upload verification ### Architecture Documentation - RUNPOD_VOLUME_MOUNT_ARCHITECTURE.md: Volume mount design - RUNPOD_S3_ARCHITECTURE_DIAGRAM.txt: S3 API vs filesystem access - DOCKERFILE_RUNPOD_FINAL_SUMMARY.md: Docker image specification ### Decision Documentation - RUNPOD_DEPLOYMENT_CHECKLIST.md: Go/no-go decision matrix (27KB) - RUNPOD_DEPLOYMENT_DECISION_TREE.md: Decision workflow - FP32_RUNPOD_DEPLOYMENT_READY.md: FP32 deployment readiness ## QAT Enhancements ### Core QAT Infrastructure - ml/src/memory_optimization/qat.rs: Enhanced QAT observer (+226 lines) - ml/src/memory_optimization/auto_batch_size.rs: OOM recovery (+84 lines) - ml/src/tft/qat_tft.rs: QAT TFT wrapper (+154 lines) - ml/src/trainers/tft.rs: QAT training integration (+433 lines) - ml/src/qat_metrics_exporter.rs: NEW - QAT metrics export ### QAT Testing - ml/tests/qat_integration_tests.rs: NEW - Integration test suite - ml/tests/qat_gradient_clipping_test.rs: NEW - Gradient clipping tests - ml/tests/qat_device_consistency_test.rs: Device mismatch tests (+205 lines) - ml/tests/qat_accuracy_validation_test.rs: Accuracy validation - ml/tests/qat_tft_integration_test.rs: TFT QAT integration ### QAT Documentation - ml/docs/QAT_GUIDE.md: Comprehensive QAT guide (+616 lines) - ml/docs/QAT_GRADIENT_CHECKPOINTING_WORKAROUND.md: NEW - Workaround guide - QAT_BLOCKERS_ROOT_CAUSE_ANALYSIS.md: P0 blocker analysis (44KB) - QAT_ACCURACY_VALIDATION_REPORT.md: Accuracy comparison - QAT_GRADIENT_CLIPPING_VALIDATION_REPORT.md: Clipping validation ### QAT Monitoring - config/grafana/dashboards/qat-training-metrics.json: NEW - Grafana dashboard ## AWS CLI Configuration ### Credentials Setup - ~/.aws/credentials: Runpod profile configured - Access Key: user_2xxA3XcIFj16yfL3aBon9niiSpr - Secret Key: (from RUNPOD_S3_SECRET) - ~/.aws/config: Iceland region (eur-is-1) ## Production Readiness ### FP32 Models: ✅ READY FOR DEPLOYMENT - DQN: 15-20s training, ~6MB GPU memory - PPO: 7-10s training, ~145MB GPU memory - MAMBA-2: 2-3 min training, ~164MB GPU memory - TFT-225: 3-5 min training, ~500MB GPU memory - Total GPU Budget: 815MB (fits on 4GB+ Tesla V100) ### QAT Models: 🔴 BLOCKED - 24 tests implemented but DO NOT COMPILE (11 errors) - 3 P0 blockers: device mismatch, gradient checkpointing, OOM recovery - Timeline: 1-2 weeks to fix (13h P0 fixes + validation) ### Wave D Features: ✅ OPERATIONAL - 225 features fully integrated - Feature extraction: 5.10μs/bar (196x faster than target) - Wave D backtest: Sharpe 2.00, Win Rate 60%, Drawdown 15% - Database migration 045: Applied cleanly, zero conflicts ## Cost Analysis ### One-Time Setup - Network Volume: $4/month (50GB SSD) - Upload costs: FREE (S3 API included) ### Per Training Run (TFT-225) - GPU: Tesla V100-PCIE-16GB @ $0.29/hr - Training Time: ~4 hours - Cost per run: $1.16 ### Monthly (20 Training Runs) - Storage: $4.00/month - Training: $23.20/month (20 runs × $1.16) - Total: $27.20/month ## Security ### Credentials Management - ✅ NO credentials in Docker image - ✅ NO credentials in Terraform state - ✅ .env gitignored and not committed - ✅ .env file private on S3 (HTTP 401 on public access) - ✅ Docker Hub repository PRIVATE (jgrusewski/foxhunt) ### Access Control - S3 API: Local client uploads only - Volume mount: Pod filesystem access only - Authentication: AWS CLI with Runpod profile required ## Next Steps 1. ✅ COMPLETE: Build Docker image 2. ⏳ PENDING: Push to Docker Hub 3. ⏳ PENDING: Deploy pod via Runpod console 4. ⏳ PENDING: Validate training on Tesla V100 ## Performance Targets - Build time: 5-10 min - Upload time: ~20 sec (90MB total) - Pod startup: ~30 sec - Training time: 3-5 min (TFT-225) - Total deployment: ~40 min from start to first training run ## Test Status - FP32 tests: 597/608 passing (98.2%) - QAT tests: 0/24 passing (compilation errors) - Overall: 2,062/2,086 passing (98.8% excluding QAT) 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
530 lines
18 KiB
Rust
530 lines
18 KiB
Rust
//! Docker Compose environment management for Vault E2E tests
|
|
//!
|
|
//! This module provides comprehensive Docker environment management:
|
|
//! - Vault server with PKI secrets engine setup
|
|
//! - PostgreSQL and Redis for service dependencies
|
|
//! - ToxiProxy for network failure simulation
|
|
//! - Service health checking and startup coordination
|
|
//! - Environment cleanup and resource management
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::process::Command as AsyncCommand;
|
|
use tokio::time::{sleep, timeout};
|
|
use tracing::{debug, info, warn, error};
|
|
|
|
use crate::VaultTestConfig;
|
|
|
|
/// Docker Compose environment manager
|
|
pub struct DockerEnvironment {
|
|
config: VaultTestConfig,
|
|
compose_file: String,
|
|
project_name: String,
|
|
services: Vec<String>,
|
|
}
|
|
|
|
impl DockerEnvironment {
|
|
/// Create new Docker environment
|
|
pub async fn new(config: &VaultTestConfig) -> Result<Self> {
|
|
let compose_file = "tests/e2e/vault_integration/docker-compose.vault.yml";
|
|
|
|
// Verify compose file exists
|
|
if !Path::new(compose_file).exists() {
|
|
return Err(anyhow::anyhow!("Docker Compose file not found: {}", compose_file));
|
|
}
|
|
|
|
let services = vec![
|
|
"vault".to_string(),
|
|
"vault-init".to_string(),
|
|
"postgres".to_string(),
|
|
"redis".to_string(),
|
|
"toxiproxy".to_string(),
|
|
];
|
|
|
|
Ok(Self {
|
|
config: config.clone(),
|
|
compose_file: compose_file.to_string(),
|
|
project_name: config.compose_project.clone(),
|
|
services,
|
|
})
|
|
}
|
|
|
|
/// Start all services with health checking
|
|
pub async fn start_all_services(&mut self) -> Result<()> {
|
|
info!("Starting Docker Compose services for Vault E2E testing");
|
|
|
|
// Clean up any existing containers
|
|
self.cleanup_existing().await?;
|
|
|
|
// Start core infrastructure services first
|
|
self.start_infrastructure_services().await?;
|
|
|
|
// Wait for Vault initialization to complete
|
|
self.wait_for_vault_setup().await?;
|
|
|
|
// Start application services
|
|
self.start_application_services().await?;
|
|
|
|
info!("All Docker services started successfully");
|
|
Ok(())
|
|
}
|
|
|
|
/// Start infrastructure services (Vault, PostgreSQL, Redis)
|
|
async fn start_infrastructure_services(&self) -> Result<()> {
|
|
info!("Starting infrastructure services");
|
|
|
|
let infrastructure_services = ["vault", "postgres", "redis", "toxiproxy"];
|
|
|
|
for service in &infrastructure_services {
|
|
info!("Starting service: {}", service);
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"up", "-d", service
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to start service: {}", service))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!(
|
|
"Failed to start service {}: {}", service, stderr
|
|
));
|
|
}
|
|
}
|
|
|
|
// Wait for services to be healthy
|
|
self.wait_for_service_health("vault", Duration::from_secs(30)).await?;
|
|
self.wait_for_service_health("postgres", Duration::from_secs(20)).await?;
|
|
self.wait_for_service_health("redis", Duration::from_secs(10)).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for Vault initialization to complete
|
|
async fn wait_for_vault_setup(&self) -> Result<()> {
|
|
info!("Starting Vault initialization");
|
|
|
|
// Start vault-init service
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"up", "vault-init"
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to start vault-init service")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!(
|
|
"Vault initialization failed: {}", stderr
|
|
));
|
|
}
|
|
|
|
// Wait for initialization to complete
|
|
self.wait_for_container_completion("vault-init", Duration::from_secs(60)).await?;
|
|
|
|
// Verify Vault is properly configured
|
|
self.verify_vault_configuration().await?;
|
|
|
|
info!("Vault initialization completed successfully");
|
|
Ok(())
|
|
}
|
|
|
|
/// Start application services (TLI, Trading Service)
|
|
async fn start_application_services(&self) -> Result<()> {
|
|
info!("Starting application services");
|
|
|
|
let app_services = ["tli-service", "trading-service"];
|
|
|
|
for service in &app_services {
|
|
info!("Starting service: {}", service);
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"up", "-d", service
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to start service: {}", service))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
warn!("Service {} failed to start: {}", service, stderr);
|
|
// Continue with other services - app services may fail initially
|
|
}
|
|
}
|
|
|
|
// Give application services time to start
|
|
sleep(Duration::from_secs(10)).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for service to become healthy
|
|
async fn wait_for_service_health(&self, service: &str, timeout_duration: Duration) -> Result<()> {
|
|
info!("Waiting for service {} to become healthy", service);
|
|
|
|
let start_time = Instant::now();
|
|
let container_name = format!("foxhunt-{}-test", service);
|
|
|
|
while start_time.elapsed() < timeout_duration {
|
|
// Check container health status
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args(["inspect", "--format", "{{.State.Health.Status}}", &container_name]);
|
|
|
|
match cmd.output().await {
|
|
Ok(output) => {
|
|
let status = String::from_utf8_lossy(&output.stdout).trim().to_lowercase();
|
|
|
|
if status == "healthy" {
|
|
info!("Service {} is healthy", service);
|
|
return Ok(());
|
|
} else if status == "unhealthy" {
|
|
return Err(anyhow::anyhow!("Service {} became unhealthy", service));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug!("Health check failed for {}: {}", service, e);
|
|
}
|
|
}
|
|
|
|
sleep(Duration::from_secs(2)).await;
|
|
}
|
|
|
|
Err(anyhow::anyhow!("Service {} did not become healthy within timeout", service))
|
|
}
|
|
|
|
/// Wait for container to complete execution
|
|
async fn wait_for_container_completion(&self, service: &str, timeout_duration: Duration) -> Result<()> {
|
|
info!("Waiting for container {} to complete", service);
|
|
|
|
let start_time = Instant::now();
|
|
let container_name = format!("foxhunt-{}", service);
|
|
|
|
while start_time.elapsed() < timeout_duration {
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args(["inspect", "--format", "{{.State.Status}}", &container_name]);
|
|
|
|
match cmd.output().await {
|
|
Ok(output) => {
|
|
let status = String::from_utf8_lossy(&output.stdout).trim().to_lowercase();
|
|
|
|
if status == "exited" {
|
|
// Check exit code
|
|
let mut exit_cmd = AsyncCommand::new("docker");
|
|
exit_cmd.args(["inspect", "--format", "{{.State.ExitCode}}", &container_name]);
|
|
|
|
let exit_output = exit_cmd.output().await?;
|
|
let exit_code_str = String::from_utf8_lossy(&exit_output.stdout);
|
|
let exit_code = exit_code_str.trim();
|
|
|
|
if exit_code == "0" {
|
|
info!("Container {} completed successfully", service);
|
|
return Ok(());
|
|
} else {
|
|
return Err(anyhow::anyhow!(
|
|
"Container {} exited with code {}", service, exit_code
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug!("Status check failed for {}: {}", service, e);
|
|
}
|
|
}
|
|
|
|
sleep(Duration::from_secs(2)).await;
|
|
}
|
|
|
|
Err(anyhow::anyhow!("Container {} did not complete within timeout", service))
|
|
}
|
|
|
|
/// Verify Vault configuration is correct
|
|
async fn verify_vault_configuration(&self) -> Result<()> {
|
|
info!("Verifying Vault configuration");
|
|
|
|
// Check if PKI secrets engine is enabled
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args([
|
|
"exec", "foxhunt-vault-test",
|
|
"vault", "secrets", "list", "-format=json"
|
|
]);
|
|
cmd.env("VAULT_ADDR", "http://localhost:8200");
|
|
cmd.env("VAULT_TOKEN", "vault-root-token");
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to list Vault secrets engines")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!("Failed to verify Vault secrets: {}", stderr));
|
|
}
|
|
|
|
let secrets_json = String::from_utf8_lossy(&output.stdout);
|
|
if !secrets_json.contains("pki/") || !secrets_json.contains("pki_int/") {
|
|
return Err(anyhow::anyhow!("PKI secrets engines not found"));
|
|
}
|
|
|
|
// Test certificate generation
|
|
let mut cert_cmd = AsyncCommand::new("docker");
|
|
cert_cmd.args([
|
|
"exec", "foxhunt-vault-test",
|
|
"vault", "write", "-format=json",
|
|
"pki_int/issue/hft-trading",
|
|
"common_name=test.foxhunt.internal",
|
|
"ttl=1h"
|
|
]);
|
|
cert_cmd.env("VAULT_ADDR", "http://localhost:8200");
|
|
cert_cmd.env("VAULT_TOKEN", "vault-root-token");
|
|
|
|
let cert_output = cert_cmd.output().await
|
|
.context("Failed to test certificate generation")?;
|
|
|
|
if !cert_output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&cert_output.stderr);
|
|
return Err(anyhow::anyhow!("Certificate generation test failed: {}", stderr));
|
|
}
|
|
|
|
info!("Vault configuration verified successfully");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get service logs for debugging
|
|
pub async fn get_service_logs(&self, service: &str) -> Result<String> {
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"logs", service
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to get logs for service: {}", service))?;
|
|
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
}
|
|
|
|
/// Stop specific service
|
|
pub async fn stop_service(&self, service: &str) -> Result<()> {
|
|
info!("Stopping service: {}", service);
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"stop", service
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to stop service: {}", service))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!(
|
|
"Failed to stop service {}: {}", service, stderr
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Start specific service
|
|
pub async fn start_service(&self, service: &str) -> Result<()> {
|
|
info!("Starting service: {}", service);
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"start", service
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to start service: {}", service))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!(
|
|
"Failed to start service {}: {}", service, stderr
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Simulate network partition using ToxiProxy
|
|
pub async fn simulate_network_partition(&self, target_service: &str) -> Result<()> {
|
|
info!("Simulating network partition for service: {}", target_service);
|
|
|
|
// Add latency and packet loss toxic
|
|
let mut cmd = AsyncCommand::new("curl");
|
|
cmd.args([
|
|
"-X", "POST",
|
|
"http://localhost:8474/proxies/vault-proxy/toxics",
|
|
"-H", "Content-Type: application/json",
|
|
"-d", r#"{"name":"latency","type":"latency","attributes":{"latency":5000}}"#
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to add network latency toxic")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!("Failed to add network toxic: {}", stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove network partition simulation
|
|
pub async fn remove_network_partition(&self) -> Result<()> {
|
|
info!("Removing network partition simulation");
|
|
|
|
let mut cmd = AsyncCommand::new("curl");
|
|
cmd.args([
|
|
"-X", "DELETE",
|
|
"http://localhost:8474/proxies/vault-proxy/toxics/latency"
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to remove network toxic")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
warn!("Failed to remove network toxic: {}", stderr);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Clean up existing containers
|
|
async fn cleanup_existing(&self) -> Result<()> {
|
|
info!("Cleaning up existing containers");
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", &self.compose_file,
|
|
"-p", &self.project_name,
|
|
"down", "-v", "--remove-orphans"
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to cleanup existing containers")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
warn!("Cleanup warning: {}", stderr);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Cleanup all resources
|
|
pub async fn cleanup(&config: &VaultTestConfig) -> Result<()> {
|
|
info!("Cleaning up Docker Compose resources");
|
|
|
|
let compose_file = "tests/e2e/vault_integration/docker-compose.vault.yml";
|
|
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", compose_file,
|
|
"-p", &config.compose_project,
|
|
"down", "-v", "--remove-orphans", "--rmi", "local"
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to cleanup Docker resources")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
warn!("Cleanup completed with warnings: {}", stderr);
|
|
}
|
|
|
|
// Remove any lingering test certificates
|
|
if Path::new(&config.cert_cache_dir).exists() {
|
|
std::fs::remove_dir_all(&config.cert_cache_dir)
|
|
.with_context(|| format!("Failed to remove cert cache dir: {}", config.cert_cache_dir))?;
|
|
}
|
|
|
|
info!("Docker cleanup completed");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get container statistics
|
|
pub async fn get_container_stats(&self) -> Result<HashMap<String, serde_json::Value>> {
|
|
let mut stats = HashMap::new();
|
|
|
|
for service in &self.services {
|
|
let container_name = format!("foxhunt-{}-test", service);
|
|
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args([
|
|
"stats", "--no-stream", "--format",
|
|
"{{json .}}", &container_name
|
|
]);
|
|
|
|
match cmd.output().await {
|
|
Ok(output) => {
|
|
if output.status.success() {
|
|
let stats_json = String::from_utf8_lossy(&output.stdout);
|
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_json) {
|
|
stats.insert(service.clone(), parsed);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug!("Failed to get stats for {}: {}", service, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
}
|
|
|
|
impl Drop for DockerEnvironment {
|
|
fn drop(&mut self) {
|
|
if self.config.cleanup_after_tests {
|
|
// Spawn cleanup task (best effort)
|
|
tokio::spawn(async move {
|
|
if let Err(e) = DockerEnvironment::cleanup(&self.config).await {
|
|
warn!("Background cleanup failed: {}", e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_docker_environment_creation() {
|
|
let config = VaultTestConfig::default();
|
|
let env = DockerEnvironment::new(&config).await.unwrap();
|
|
assert_eq!(env.project_name, config.compose_project);
|
|
assert!(env.services.contains(&"vault".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cleanup_config() {
|
|
let mut config = VaultTestConfig::default();
|
|
config.cleanup_after_tests = false;
|
|
|
|
// Should not cleanup when disabled
|
|
assert!(!config.cleanup_after_tests);
|
|
}
|
|
} |