use crate::config::CliConfig;
use crate::workspace_config::WorkspaceConfig;
use comfy_table::{presets::NOTHING, Table, TableComponent};
use std::path::{Path, PathBuf};
use std::time::Instant;
fn load_workspace() -> eyre::Result<(WorkspaceConfig, PathBuf)> {
let (config, root) = WorkspaceConfig::find_and_load()?;
config.validate_paths(&root)?;
Ok((config, root))
}
fn load_config_from_workspace(
root: &Path,
ws: &WorkspaceConfig,
env: Option<&str>,
) -> eyre::Result<CliConfig> {
let first = ws
.services
.first()
.ok_or_else(|| eyre::eyre!("No services in workspace"))?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(root.join(&first.path))?;
let config = CliConfig::load_with_env(env);
std::env::set_current_dir(&original_dir)?;
config
}
fn make_table(headers: Vec<&str>) -> Table {
let mut table = Table::new();
table.load_preset(NOTHING);
table.set_style(TableComponent::HeaderLines, '-');
table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
table.set_header(headers);
table
}
pub async fn deploy(
skip: &[String],
env: Option<&str>,
tenant_override: Option<&str>,
) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
println!("Workspace: {}", ws.workspace.name);
if let Some(name) = env {
println!("Environment: {}", name);
}
let deploy_services = ws.deploy_list(env);
let deploy_services: Vec<_> = deploy_services
.into_iter()
.filter(|s| !skip.contains(&s.name))
.collect();
if deploy_services.is_empty() {
println!("Nothing to deploy.");
return Ok(());
}
if !skip.is_empty() {
println!("Skipping: {}", skip.join(", "));
}
let original_dir = std::env::current_dir()?;
let mut results: Vec<(String, Result<(), String>)> = Vec::new();
for svc in &deploy_services {
let service_dir = root.join(&svc.path);
println!("\nDeploying '{}'...", svc.name);
std::env::set_current_dir(&service_dir)?;
match super::deploy::run(false, None, env, tenant_override).await {
Ok(_) => results.push((svc.name.clone(), Ok(()))),
Err(e) => {
let msg = format!("{}", e);
eprintln!(" Failed: {}", msg);
results.push((svc.name.clone(), Err(msg)));
}
}
}
std::env::set_current_dir(&original_dir)?;
println!();
let mut table = make_table(vec!["SERVICE", "STATUS"]);
let mut failures = 0;
let deployed: Vec<String> = results
.iter()
.filter(|(_, r)| r.is_ok())
.map(|(n, _)| n.clone())
.collect();
for (name, result) in &results {
let status = match result {
Ok(_) => "deployed",
Err(_) => {
failures += 1;
"FAILED"
}
};
table.add_row(vec![name.as_str(), status]);
}
println!("{table}");
let seed_services = ws.seed_list(env);
let seed_services: Vec<_> = seed_services
.into_iter()
.filter(|s| deployed.contains(&s.name))
.collect();
if !seed_services.is_empty() {
println!();
run_seed_phase(&root, &ws, &seed_services, env).await;
}
if failures > 0 {
eyre::bail!("{} service(s) failed to deploy", failures);
}
Ok(())
}
async fn run_seed_phase(
root: &std::path::Path,
ws: &WorkspaceConfig,
seed_services: &[&crate::workspace_config::WorkspaceService],
env: Option<&str>,
) {
println!("Seeding...");
let config = match load_config_from_workspace(root, ws, env) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to load config for seeding: {}", e);
return;
}
};
for svc in seed_services {
let seed_path = match &svc.seed {
Some(p) => root.join(p),
None => continue,
};
let service_id = match find_service_id(&config, &svc.name).await {
Ok(id) => id,
Err(e) => {
eprintln!(" {} — failed to resolve: {}", svc.name, e);
continue;
}
};
match run_seed(&config, &service_id, &seed_path).await {
Ok(rows) => println!(" {} — {} rows seeded", svc.name, rows),
Err(e) => eprintln!(" {} — seed failed: {}", svc.name, e),
}
}
}
async fn find_service_id(config: &CliConfig, service_name: &str) -> eyre::Result<String> {
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
body["services"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|s| s["name"].as_str() == Some(service_name))
.and_then(|s| s["id"].as_str())
})
.map(String::from)
.ok_or_else(|| eyre::eyre!("Service '{}' not found", service_name))
}
async fn run_seed(
config: &CliConfig,
service_id: &str,
seed_path: &std::path::Path,
) -> eyre::Result<u64> {
let data = std::fs::read_to_string(seed_path)?;
let mut payload: serde_json::Value = serde_json::from_str(&data)?;
if payload.get("tables").is_none() {
payload = serde_json::json!({ "tables": payload });
}
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::POST,
&format!("{}/api/services/{}/seed", config.api_url, service_id),
)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Seed failed ({}): {}", status, body);
}
let body: serde_json::Value = resp.json().await?;
Ok(body["total_rows"].as_u64().unwrap_or(0))
}
pub async fn test(skip: &[String], run_all: bool) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
println!("Workspace: {}", ws.workspace.name);
println!();
let services: Vec<_> = ws
.services
.iter()
.filter(|s| !skip.contains(&s.name))
.collect();
if services.is_empty() {
println!("No services to test.");
return Ok(());
}
let original_dir = std::env::current_dir()?;
let mut results: Vec<(String, bool, String)> = Vec::new();
for svc in &services {
let service_dir = root.join(&svc.path);
if !service_dir.join("Cargo.toml").exists() {
results.push((svc.name.clone(), true, "skipped".to_string()));
continue;
}
std::env::set_current_dir(&service_dir)?;
print!("Testing {}... ", svc.name);
let start = Instant::now();
let mut args = vec!["test"];
if !run_all {
args.push("--quiet");
}
let output = std::process::Command::new("cargo").args(&args).output();
let elapsed = start.elapsed();
let time_str = format!("{:.1}s", elapsed.as_secs_f64());
match output {
Ok(o) if o.status.success() => {
println!("passed ({})", time_str);
results.push((svc.name.clone(), true, time_str));
}
Ok(o) => {
println!("FAILED ({})", time_str);
if run_all {
let stderr = String::from_utf8_lossy(&o.stderr);
let stdout = String::from_utf8_lossy(&o.stdout);
if !stdout.is_empty() {
eprintln!("{}", stdout);
}
if !stderr.is_empty() {
eprintln!("{}", stderr);
}
}
results.push((svc.name.clone(), false, time_str));
}
Err(e) => {
println!("ERROR");
results.push((svc.name.clone(), false, format!("error: {}", e)));
}
}
}
std::env::set_current_dir(&original_dir)?;
println!();
let mut table = make_table(vec!["SERVICE", "RESULT", "TIME"]);
let mut passed = 0;
let mut failed = 0;
let mut skipped = 0;
for (name, success, time) in &results {
if time == "skipped" {
skipped += 1;
table.add_row(vec![name.as_str(), "skipped", "-"]);
} else if *success {
passed += 1;
table.add_row(vec![name.as_str(), "passed", time.as_str()]);
} else {
failed += 1;
table.add_row(vec![name.as_str(), "FAILED", time.as_str()]);
}
}
println!("{table}");
println!();
let mut summary = format!("{} passed", passed);
if failed > 0 {
summary.push_str(&format!(", {} failed", failed));
}
if skipped > 0 {
summary.push_str(&format!(", {} skipped", skipped));
}
println!("{}", summary);
if failed > 0 {
eyre::bail!("{} service(s) failed tests", failed);
}
Ok(())
}
pub async fn status(env: Option<&str>) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
println!("Workspace: {}", ws.workspace.name);
let config = load_config_from_workspace(&root, &ws, env)?;
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
println!();
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
let api_services = body["services"].as_array();
let mut table = make_table(vec!["SERVICE", "VERSION", "STATUS", "MODE"]);
for svc in &ws.services {
let api_svc =
api_services.and_then(|arr| arr.iter().find(|s| s["name"].as_str() == Some(&svc.name)));
match api_svc {
Some(s) => {
let version = format!("v{}", s["current_version"]);
let status = s["status"].as_str().unwrap_or("?");
let mode = s["mode"].as_str().unwrap_or("?");
table.add_row(vec![svc.name.as_str(), &version, status, mode]);
}
None => {
table.add_row(vec![svc.name.as_str(), "-", "not deployed", "-"]);
}
}
}
println!("{table}");
println!("\n{} service(s)", ws.services.len());
Ok(())
}
pub async fn seed(env: Option<&str>) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
println!("Workspace: {}", ws.workspace.name);
let seed_services = ws.seed_list(env);
if seed_services.is_empty() {
println!("No services configured for seeding in this environment.");
return Ok(());
}
run_seed_phase(&root, &ws, &seed_services, env).await;
Ok(())
}
fn platform_api_key() -> eyre::Result<String> {
std::env::var("CUFFLINK_PLATFORM_API_KEY")
.map_err(|_| eyre::eyre!("CUFFLINK_PLATFORM_API_KEY env var required for preview commands"))
}
fn platform_auth_header(api_key: &str) -> String {
format!("ApiKey {}", api_key)
}
pub async fn preview_create(
name: &str,
source_tenant: &str,
env: Option<&str>,
) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
let config = load_config_from_workspace(&root, &ws, env)?;
let api_key = platform_api_key()?;
let preview_slug = format!("preview-{}", name);
println!("Creating preview environment '{}'...", preview_slug);
let http = reqwest::Client::new();
let resp = http
.get(format!("{}/api/tenants", config.api_url))
.header("Authorization", platform_auth_header(&api_key))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to list tenants ({}): {}", status, body);
}
let tenants_resp: serde_json::Value = resp.json().await?;
let source = tenants_resp["tenants"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|t| t["slug"].as_str() == Some(source_tenant))
})
.ok_or_else(|| eyre::eyre!("Source tenant '{}' not found", source_tenant))?;
println!(" Creating tenant '{}'...", preview_slug);
let create_body = serde_json::json!({
"name": format!("Preview {}", name),
"slug": preview_slug,
"keycloak_url": source["keycloak_url"],
"keycloak_realm": source["keycloak_realm"],
});
let resp = http
.post(format!("{}/api/tenants", config.api_url))
.header("Authorization", platform_auth_header(&api_key))
.json(&create_body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to create tenant ({}): {}", status, body);
}
println!(" Tenant created");
println!(" Deploying services...");
let deploy_services = ws.deploy_list(env);
let original_dir = std::env::current_dir()?;
for svc in &deploy_services {
let service_dir = root.join(&svc.path);
print!(" {}... ", svc.name);
std::env::set_current_dir(&service_dir)?;
match super::deploy::run(false, None, env, Some(&preview_slug)).await {
Ok(_) => println!("deployed"),
Err(e) => println!("FAILED: {}", e),
}
}
std::env::set_current_dir(&original_dir)?;
println!(" Cloning data from '{}'...", source_tenant);
let resp = http
.get(format!("{}/api/services", config.api_url))
.header("Authorization", platform_auth_header(&api_key))
.header("X-Tenant-Slug", source_tenant)
.send()
.await?;
let services_resp: serde_json::Value = resp.json().await?;
let source_services = services_resp["services"].as_array();
if let Some(services) = source_services {
for svc in services {
let svc_id = match svc["id"].as_str() {
Some(id) => id,
None => continue,
};
let svc_name = svc["name"].as_str().unwrap_or("?");
let resp = http
.post(format!(
"{}/api/services/{}/backup/export",
config.api_url, svc_id
))
.header("Authorization", platform_auth_header(&api_key))
.header("X-Tenant-Slug", source_tenant)
.json(&serde_json::json!({}))
.send()
.await?;
if !resp.status().is_success() {
println!(" {} — export failed, skipping", svc_name);
continue;
}
let export_resp: serde_json::Value = resp.json().await?;
let export_job_id = match export_resp["job_id"].as_str() {
Some(id) => id.to_string(),
None => continue,
};
let export_job = poll_platform_job(
&http,
&config.api_url,
&api_key,
source_tenant,
svc_id,
&export_job_id,
)
.await?;
if export_job["status"].as_str() != Some("completed") {
println!(" {} — export failed", svc_name);
continue;
}
let s3_key = match export_job["s3_key"].as_str() {
Some(k) => k,
None => continue,
};
let resp = http
.get(format!("{}/api/services", config.api_url))
.header("Authorization", platform_auth_header(&api_key))
.header("X-Tenant-Slug", &preview_slug)
.send()
.await?;
let preview_services: serde_json::Value = resp.json().await?;
let preview_svc_id = preview_services["services"].as_array().and_then(|arr| {
arr.iter()
.find(|s| s["name"].as_str() == Some(svc_name))
.and_then(|s| s["id"].as_str())
});
let preview_svc_id = match preview_svc_id {
Some(id) => id.to_string(),
None => continue,
};
let resp = http
.post(format!(
"{}/api/services/{}/backup/restore",
config.api_url, preview_svc_id
))
.header("Authorization", platform_auth_header(&api_key))
.header("X-Tenant-Slug", &preview_slug)
.json(&serde_json::json!({ "s3_key": s3_key }))
.send()
.await?;
if !resp.status().is_success() {
println!(" {} — restore failed", svc_name);
continue;
}
let restore_resp: serde_json::Value = resp.json().await?;
let restore_job_id = match restore_resp["job_id"].as_str() {
Some(id) => id.to_string(),
None => continue,
};
let restore_job = poll_platform_job(
&http,
&config.api_url,
&api_key,
&preview_slug,
&preview_svc_id,
&restore_job_id,
)
.await?;
let rows = restore_job["processed_rows"].as_i64().unwrap_or(0);
println!(" {} — {} rows cloned", svc_name, rows);
}
}
println!();
println!("Preview environment '{}' ready", preview_slug);
Ok(())
}
pub async fn preview_destroy(name: &str, env: Option<&str>) -> eyre::Result<()> {
let (ws, root) = load_workspace()?;
let config = load_config_from_workspace(&root, &ws, env)?;
let api_key = platform_api_key()?;
let preview_slug = format!("preview-{}", name);
println!("Destroying preview environment '{}'...", preview_slug);
let http = reqwest::Client::new();
let resp = http
.delete(format!("{}/api/tenants/{}", config.api_url, preview_slug))
.header("Authorization", platform_auth_header(&api_key))
.send()
.await?;
if resp.status().is_success() {
println!("Preview environment '{}' destroyed", preview_slug);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!(
"Failed to destroy preview environment ({}): {}",
status,
body
);
}
Ok(())
}
async fn poll_platform_job(
http: &reqwest::Client,
api_url: &str,
api_key: &str,
tenant_slug: &str,
service_id: &str,
job_id: &str,
) -> eyre::Result<serde_json::Value> {
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let resp = http
.get(format!(
"{}/api/services/{}/backup/jobs/{}",
api_url, service_id, job_id
))
.header("Authorization", platform_auth_header(api_key))
.header("X-Tenant-Slug", tenant_slug)
.send()
.await?;
if !resp.status().is_success() {
continue;
}
let job: serde_json::Value = resp.json().await?;
match job["status"].as_str() {
Some("completed" | "failed" | "cancelled") => return Ok(job),
_ => continue,
}
}
}