use mockforge_core::config::{DeceptiveDeployConfig, ServerConfig};
use mockforge_core::load_config;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use tracing::{info, warn};
#[derive(clap::Subcommand)]
pub enum DeploySubcommand {
Deploy {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(short, long)]
spec: Option<PathBuf>,
#[arg(long)]
auto_tunnel: bool,
#[arg(long)]
custom_domain: Option<String>,
#[arg(long)]
production_preset: bool,
#[arg(long)]
start_server: bool,
#[arg(long)]
dry_run: bool,
},
Status {
#[arg(short, long)]
config: Option<PathBuf>,
},
Stop {
#[arg(short, long)]
config: Option<PathBuf>,
},
}
pub async fn handle_deploy_command(cmd: DeploySubcommand) -> anyhow::Result<()> {
match cmd {
DeploySubcommand::Deploy {
config,
spec,
auto_tunnel,
custom_domain,
production_preset,
start_server,
dry_run,
} => {
deploy_mock_api(
config,
spec,
auto_tunnel,
custom_domain,
production_preset,
start_server,
dry_run,
)
.await
}
DeploySubcommand::Status { config } => get_deployment_status(config).await,
DeploySubcommand::Stop { config } => stop_deployment(config).await,
}
}
#[derive(Debug, Serialize, Deserialize)]
struct DeploymentMetadata {
pid: Option<u32>,
http_port: u16,
admin_port: Option<u16>,
tunnel_url: Option<String>,
config_path: String,
spec_path: Option<String>,
deployed_at: String,
}
async fn deploy_mock_api(
config_path: Option<PathBuf>,
spec_path: Option<PathBuf>,
auto_tunnel: bool,
custom_domain: Option<String>,
production_preset: bool,
start_server: bool,
dry_run: bool,
) -> anyhow::Result<()> {
info!("🚀 Starting deceptive deploy...");
let config_path_clone = config_path.clone();
let mut server_config = if let Some(config_path) = config_path {
load_config(&config_path)
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?
} else {
let default_paths = [
PathBuf::from("mockforge.yaml"),
PathBuf::from("config.yaml"),
PathBuf::from("mockforge.yml"),
PathBuf::from("config.yml"),
];
let mut found_config = None;
for path in &default_paths {
if path.exists() {
found_config = Some(path.clone());
break;
}
}
if let Some(path) = found_config {
load_config(&path)
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?
} else {
warn!("No config file found, using defaults");
ServerConfig::default()
}
};
if !server_config.deceptive_deploy.enabled {
if production_preset {
server_config.deceptive_deploy = DeceptiveDeployConfig::production_preset();
info!("Applied production preset configuration");
} else {
server_config.deceptive_deploy.enabled = true;
info!("Enabled deceptive deploy mode");
}
}
if auto_tunnel {
server_config.deceptive_deploy.auto_tunnel = true;
}
if let Some(domain) = custom_domain {
server_config.deceptive_deploy.custom_domain = Some(domain);
}
let spec_path_for_serve = spec_path.clone();
if let Some(spec) = spec_path {
server_config.http.openapi_spec = Some(spec.to_string_lossy().to_string());
}
if server_config.http.openapi_spec.is_none() {
return Err(anyhow::anyhow!(
"OpenAPI spec is required for deployment. Use --spec to specify a spec file."
));
}
info!("✅ Configuration loaded and validated");
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("📋 Deceptive Deploy Configuration");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(" Enabled: {}", server_config.deceptive_deploy.enabled);
println!(" Auto tunnel: {}", server_config.deceptive_deploy.auto_tunnel);
if let Some(domain) = &server_config.deceptive_deploy.custom_domain {
println!(" Custom domain: {}", domain);
}
if !server_config.deceptive_deploy.headers.is_empty() {
println!(
" Production headers: {} configured",
server_config.deceptive_deploy.headers.len()
);
for (key, value) in &server_config.deceptive_deploy.headers {
println!(
" - {}: {}",
key,
if value.len() > 50 {
format!("{}...", &value[..50])
} else {
value.clone()
}
);
}
}
if let Some(rate_limit) = &server_config.deceptive_deploy.rate_limit {
println!(
" Rate limiting: {} req/min (burst: {})",
rate_limit.requests_per_minute, rate_limit.burst
);
}
if let Some(cors) = &server_config.deceptive_deploy.cors {
println!(
" CORS: {} origins, {} methods",
cors.allowed_origins.len(),
cors.allowed_methods.len()
);
}
if server_config.deceptive_deploy.oauth.is_some() {
println!(" OAuth: Configured");
}
println!(" HTTP Port: {}", server_config.http.port);
if server_config.admin.enabled {
println!(" Admin Port: {}", server_config.admin.port);
}
if let Some(spec) = &server_config.http.openapi_spec {
println!(" OpenAPI Spec: {}", spec);
}
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
if dry_run {
info!("🔍 Dry-run mode: Configuration validated successfully");
info!("💡 Remove --dry-run to actually deploy");
return Ok(());
}
if !start_server {
info!("🎯 Configuration ready for deployment");
info!("💡 Use 'mockforge deploy --start-server' to start the server");
if server_config.deceptive_deploy.auto_tunnel {
info!("🌐 Tunnel will be started automatically when server is ready");
}
return Ok(());
}
info!("🚀 Starting server...");
let effective_config_path = if let Some(ref path) = config_path_clone {
path.clone()
} else {
let default_paths = [
PathBuf::from("mockforge.yaml"),
PathBuf::from("config.yaml"),
PathBuf::from("mockforge.yml"),
PathBuf::from("config.yml"),
];
default_paths
.iter()
.find(|p| p.exists())
.cloned()
.unwrap_or_else(|| PathBuf::from("mockforge.yaml"))
};
let config_was_modified = if effective_config_path.exists() {
if let Ok(existing_config) = load_config(&effective_config_path).await {
existing_config.deceptive_deploy.enabled != server_config.deceptive_deploy.enabled
|| existing_config.deceptive_deploy.auto_tunnel
!= server_config.deceptive_deploy.auto_tunnel
|| existing_config.deceptive_deploy.custom_domain
!= server_config.deceptive_deploy.custom_domain
} else {
true }
} else {
true };
if config_was_modified {
use serde_yaml;
let config_yaml = serde_yaml::to_string(&server_config)?;
fs::write(&effective_config_path, config_yaml)?;
info!("💾 Saved updated configuration to {}", effective_config_path.display());
}
let deployment_meta = DeploymentMetadata {
pid: Some(process::id()),
http_port: server_config.http.port,
admin_port: if server_config.admin.enabled {
Some(server_config.admin.port)
} else {
None
},
tunnel_url: None, config_path: effective_config_path.to_string_lossy().to_string(),
spec_path: server_config.http.openapi_spec.clone(),
deployed_at: chrono::Utc::now().to_rfc3339(),
};
let mockforge_dir = Path::new(".mockforge");
if !mockforge_dir.exists() {
fs::create_dir_all(mockforge_dir)?;
}
let metadata_path = mockforge_dir.join("deployment.json");
let metadata_json = serde_json::to_string_pretty(&deployment_meta)?;
fs::write(&metadata_path, metadata_json)?;
info!("💾 Saved deployment metadata to {}", metadata_path.display());
info!("🎯 Starting MockForge server...");
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("🚀 Deceptive Deploy - Starting Server");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
use crate::serve;
serve::handle_serve(serve::ServeArgs {
config_path: Some(effective_config_path.clone()),
admin: server_config.admin.enabled,
tracing_environment: "production".to_string(),
spec: spec_path_for_serve.into_iter().collect(),
..serve::ServeArgs::default()
})
.await
.map_err(|e| anyhow::anyhow!("Failed to start server: {}", e))?;
Ok(())
}
async fn get_deployment_status(_config_path: Option<PathBuf>) -> anyhow::Result<()> {
info!("📊 Getting deployment status...");
let metadata_path = Path::new(".mockforge/deployment.json");
if !metadata_path.exists() {
warn!("❌ No deployment found");
warn!("💡 Run 'mockforge deploy' to create a deployment");
return Ok(());
}
let metadata_json = fs::read_to_string(metadata_path)?;
let metadata: DeploymentMetadata = serde_json::from_str(&metadata_json)?;
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("📊 Deployment Status");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
let is_running = if let Some(pid) = metadata.pid {
#[cfg(unix)]
{
use std::process::Command;
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
} else {
false
};
if is_running {
println!(" Status: ✅ Running");
} else {
println!(" Status: ⏸️ Stopped");
}
if let Some(pid) = metadata.pid {
println!(" Process ID: {}", pid);
}
println!(" HTTP Port: {}", metadata.http_port);
if let Some(admin_port) = metadata.admin_port {
println!(" Admin Port: {}", admin_port);
}
if let Some(tunnel_url) = &metadata.tunnel_url {
println!(" Tunnel URL: {}", tunnel_url);
}
println!(" Config: {}", metadata.config_path);
if let Some(spec) = &metadata.spec_path {
println!(" OpenAPI Spec: {}", spec);
}
println!(" Deployed: {}", metadata.deployed_at);
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
Ok(())
}
async fn stop_deployment(_config_path: Option<PathBuf>) -> anyhow::Result<()> {
info!("🛑 Stopping deployment...");
let metadata_path = Path::new(".mockforge/deployment.json");
if !metadata_path.exists() {
warn!("❌ No deployment found to stop");
return Ok(());
}
let metadata_json = fs::read_to_string(metadata_path)?;
let metadata: DeploymentMetadata = serde_json::from_str(&metadata_json)?;
if let Some(pid) = metadata.pid {
#[cfg(unix)]
{
use std::process::Command;
info!("🛑 Stopping process {}...", pid);
let term_result = Command::new("kill").args(["-TERM", &pid.to_string()]).output();
match term_result {
Ok(output) if output.status.success() => {
info!("✅ Sent SIGTERM to process {}", pid);
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let still_running = Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if still_running {
warn!("⚠️ Process still running, sending SIGKILL...");
let _ = Command::new("kill").args(["-KILL", &pid.to_string()]).output();
}
}
Ok(_) => {
warn!("⚠️ Process {} may not be running", pid);
}
Err(e) => {
warn!("⚠️ Failed to stop process {}: {}", pid, e);
}
}
}
#[cfg(not(unix))]
{
warn!("⚠️ Process stopping not supported on this platform");
warn!("💡 Please stop the server manually (Ctrl+C or kill process {})", pid);
}
}
if metadata_path.exists() {
fs::remove_file(metadata_path)?;
info!("🗑️ Removed deployment metadata");
}
info!("✅ Deployment stopped");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deploy_subcommand_deploy() {
let cmd = DeploySubcommand::Deploy {
config: Some(PathBuf::from("config.yaml")),
spec: Some(PathBuf::from("api.yaml")),
auto_tunnel: true,
custom_domain: Some("api.example.com".to_string()),
production_preset: false,
start_server: false,
dry_run: true,
};
match cmd {
DeploySubcommand::Deploy {
config,
spec,
auto_tunnel,
custom_domain,
production_preset,
start_server,
dry_run,
} => {
assert_eq!(config, Some(PathBuf::from("config.yaml")));
assert_eq!(spec, Some(PathBuf::from("api.yaml")));
assert!(auto_tunnel);
assert_eq!(custom_domain, Some("api.example.com".to_string()));
assert!(!production_preset);
assert!(!start_server);
assert!(dry_run);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deploy_subcommand_status() {
let cmd = DeploySubcommand::Status {
config: Some(PathBuf::from("config.yaml")),
};
match cmd {
DeploySubcommand::Status { config } => {
assert_eq!(config, Some(PathBuf::from("config.yaml")));
}
_ => panic!("Expected Status command"),
}
}
#[test]
fn test_deploy_subcommand_stop() {
let cmd = DeploySubcommand::Stop { config: None };
match cmd {
DeploySubcommand::Stop { config } => {
assert!(config.is_none());
}
_ => panic!("Expected Stop command"),
}
}
#[test]
fn test_deployment_metadata_serialization() {
let metadata = DeploymentMetadata {
pid: Some(1234),
http_port: 3000,
admin_port: Some(9080),
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
config_path: "mockforge.yaml".to_string(),
spec_path: Some("api.yaml".to_string()),
deployed_at: "2025-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&metadata).expect("Failed to serialize");
assert!(json.contains("1234"));
assert!(json.contains("3000"));
assert!(json.contains("ngrok.io"));
}
#[test]
fn test_deployment_metadata_deserialization() {
let json = r#"{
"pid": 5678,
"http_port": 8080,
"admin_port": 9090,
"tunnel_url": "https://test.localtunnel.me",
"config_path": "config.yaml",
"spec_path": "openapi.json",
"deployed_at": "2025-01-15T12:00:00Z"
}"#;
let metadata: DeploymentMetadata =
serde_json::from_str(json).expect("Failed to deserialize");
assert_eq!(metadata.pid, Some(5678));
assert_eq!(metadata.http_port, 8080);
assert_eq!(metadata.admin_port, Some(9090));
assert_eq!(metadata.tunnel_url, Some("https://test.localtunnel.me".to_string()));
assert_eq!(metadata.config_path, "config.yaml");
assert_eq!(metadata.spec_path, Some("openapi.json".to_string()));
}
#[test]
fn test_deployment_metadata_optional_fields() {
let metadata = DeploymentMetadata {
pid: None,
http_port: 3000,
admin_port: None,
tunnel_url: None,
config_path: "config.yaml".to_string(),
spec_path: None,
deployed_at: "2025-01-01T00:00:00Z".to_string(),
};
assert!(metadata.pid.is_none());
assert!(metadata.admin_port.is_none());
assert!(metadata.tunnel_url.is_none());
assert!(metadata.spec_path.is_none());
}
#[test]
fn test_deployment_metadata_debug_format() {
let metadata = DeploymentMetadata {
pid: Some(9999),
http_port: 4000,
admin_port: Some(8080),
tunnel_url: None,
config_path: "test.yaml".to_string(),
spec_path: Some("spec.yaml".to_string()),
deployed_at: "2025-01-01T00:00:00Z".to_string(),
};
let debug_str = format!("{:?}", metadata);
assert!(debug_str.contains("9999"));
assert!(debug_str.contains("4000"));
assert!(debug_str.contains("test.yaml"));
}
#[test]
fn test_deploy_with_production_preset() {
let cmd = DeploySubcommand::Deploy {
config: None,
spec: Some(PathBuf::from("api.yaml")),
auto_tunnel: false,
custom_domain: None,
production_preset: true,
start_server: false,
dry_run: true,
};
match cmd {
DeploySubcommand::Deploy {
production_preset, ..
} => {
assert!(production_preset);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deploy_without_config() {
let cmd = DeploySubcommand::Deploy {
config: None,
spec: Some(PathBuf::from("api.yaml")),
auto_tunnel: false,
custom_domain: None,
production_preset: false,
start_server: false,
dry_run: false,
};
match cmd {
DeploySubcommand::Deploy { config, .. } => {
assert!(config.is_none());
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deploy_with_custom_domain() {
let domain = "api.mycompany.com";
let cmd = DeploySubcommand::Deploy {
config: None,
spec: None,
auto_tunnel: false,
custom_domain: Some(domain.to_string()),
production_preset: false,
start_server: false,
dry_run: false,
};
match cmd {
DeploySubcommand::Deploy { custom_domain, .. } => {
assert_eq!(custom_domain, Some(domain.to_string()));
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deploy_start_server_flag() {
let cmd = DeploySubcommand::Deploy {
config: None,
spec: None,
auto_tunnel: false,
custom_domain: None,
production_preset: false,
start_server: true,
dry_run: false,
};
match cmd {
DeploySubcommand::Deploy {
start_server,
dry_run,
..
} => {
assert!(start_server);
assert!(!dry_run);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deploy_dry_run_flag() {
let cmd = DeploySubcommand::Deploy {
config: None,
spec: None,
auto_tunnel: false,
custom_domain: None,
production_preset: false,
start_server: false,
dry_run: true,
};
match cmd {
DeploySubcommand::Deploy { dry_run, .. } => {
assert!(dry_run);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deployment_metadata_with_tunnel() {
let metadata = DeploymentMetadata {
pid: Some(1234),
http_port: 3000,
admin_port: Some(9080),
tunnel_url: Some("https://mock-api.ngrok.io".to_string()),
config_path: "config.yaml".to_string(),
spec_path: Some("openapi.yaml".to_string()),
deployed_at: "2025-01-01T12:00:00Z".to_string(),
};
assert!(metadata.tunnel_url.is_some());
assert!(metadata.tunnel_url.unwrap().contains("ngrok.io"));
}
#[test]
fn test_deployment_metadata_timestamp_format() {
let metadata = DeploymentMetadata {
pid: None,
http_port: 3000,
admin_port: None,
tunnel_url: None,
config_path: "config.yaml".to_string(),
spec_path: None,
deployed_at: "2025-01-15T14:30:00+00:00".to_string(),
};
assert!(metadata.deployed_at.contains("T"));
assert!(metadata.deployed_at.contains(":"));
}
#[test]
fn test_deploy_all_flags_enabled() {
let cmd = DeploySubcommand::Deploy {
config: Some(PathBuf::from("custom.yaml")),
spec: Some(PathBuf::from("spec.yaml")),
auto_tunnel: true,
custom_domain: Some("api.test.com".to_string()),
production_preset: true,
start_server: true,
dry_run: false,
};
match cmd {
DeploySubcommand::Deploy {
config,
spec,
auto_tunnel,
custom_domain,
production_preset,
start_server,
dry_run,
} => {
assert!(config.is_some());
assert!(spec.is_some());
assert!(auto_tunnel);
assert!(custom_domain.is_some());
assert!(production_preset);
assert!(start_server);
assert!(!dry_run);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_deployment_metadata_ports() {
let metadata = DeploymentMetadata {
pid: Some(1),
http_port: 8080,
admin_port: Some(8081),
tunnel_url: None,
config_path: "config.yaml".to_string(),
spec_path: None,
deployed_at: "2025-01-01T00:00:00Z".to_string(),
};
assert_eq!(metadata.http_port, 8080);
assert_eq!(metadata.admin_port, Some(8081));
}
#[test]
fn test_pathbuf_fields_in_deploy() {
let config_path = PathBuf::from("/path/to/config.yaml");
let spec_path = PathBuf::from("/path/to/spec.json");
let cmd = DeploySubcommand::Deploy {
config: Some(config_path.clone()),
spec: Some(spec_path.clone()),
auto_tunnel: false,
custom_domain: None,
production_preset: false,
start_server: false,
dry_run: false,
};
match cmd {
DeploySubcommand::Deploy { config, spec, .. } => {
assert_eq!(config, Some(config_path));
assert_eq!(spec, Some(spec_path));
}
_ => panic!("Expected Deploy command"),
}
}
}