use crate::config::CliConfig;
use reqwest::Method;
use serde_json::{json, Value};
fn listed(body: &Value, name: &str) -> eyre::Result<String> {
body["services"]
.as_array()
.and_then(|services| services.iter().find(|s| s["name"].as_str() == Some(name)))
.ok_or_else(|| eyre::eyre!("Service '{}' not found", name))?["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| eyre::eyre!("Service has no ID"))
}
fn delete_request(api_url: &str, id: &str, force: bool) -> (Method, String) {
let query = if force { "?force=true" } else { "" };
(
Method::DELETE,
format!("{api_url}/api/services/{id}{query}"),
)
}
fn status_request(api_url: &str, id: &str, status: &str) -> (Method, String, Value) {
(
Method::PATCH,
format!("{api_url}/api/services/{id}/status"),
json!({ "status": status }),
)
}
async fn find(config: &CliConfig, name: &str) -> eyre::Result<String> {
if let Some(ref env_name) = config.env_name {
println!("Environment: {}", env_name);
}
let resp = config
.auth_request(
&config.http_client(),
Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
if !resp.status().is_success() {
eyre::bail!("Failed to list services: {}", resp.status());
}
listed(&resp.json().await?, name)
}
pub async fn set_status(name: &str, status: &str, env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
let id = find(&config, name).await?;
let (method, url, body) = status_request(&config.api_url, &id, status);
let resp = config
.auth_request(&config.http_client(), method, &url)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
let action = if status == "active" {
"enabled"
} else {
"disabled"
};
println!("Service '{}' {}.", name, action);
} else {
let status_code = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!(
"Failed to update service status ({}): {}",
status_code,
body
);
}
Ok(())
}
pub async fn delete(name: &str, yes: bool, force: bool, env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
let id = find(&config, name).await?;
if !yes {
println!(
"This will permanently delete service '{}' and all its configs, deployments, and data.",
name
);
print!("Are you sure? [y/N] ");
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let (method, url) = delete_request(&config.api_url, &id, force);
let resp = config
.auth_request(&config.http_client(), method, &url)
.send()
.await?;
if resp.status().is_success() {
println!("Service '{}' deleted.", name);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to delete service ({}): {}", status, body);
}
Ok(())
}
fn required_features(service: &serde_json::Value) -> String {
let features: Vec<&str> = service["required_platform_features"]
.as_array()
.map(|features| features.iter().filter_map(|f| f.as_str()).collect())
.unwrap_or_default();
if features.is_empty() {
"-".to_string()
} else {
features.join(",")
}
}
pub async fn run(features: bool, env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
if !resp.status().is_success() {
eyre::bail!("Failed to list services: {}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let services = body["services"].as_array();
match services {
Some(services) if !services.is_empty() => {
use comfy_table::{presets::NOTHING, Table, TableComponent};
let mut table = Table::new();
table.load_preset(NOTHING);
table.set_style(TableComponent::HeaderLines, '-');
table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
let header = ["NAME", "VERSION", "MODE", "STATUS", "TENANT", "COMMIT"];
table.set_header(
header
.into_iter()
.chain(features.then_some("FEATURES"))
.collect::<Vec<_>>(),
);
for svc in services {
let commit = svc["commit_hash"]
.as_str()
.map(|h| if h.len() > 7 { &h[..7] } else { h })
.unwrap_or("-");
let row = [
svc["name"].as_str().unwrap_or("?").to_string(),
format!("v{}", svc["current_version"]),
svc["mode"].as_str().unwrap_or("?").to_string(),
svc["status"].as_str().unwrap_or("?").to_string(),
svc["tenant_slug"].as_str().unwrap_or("?").to_string(),
commit.to_string(),
];
table.add_row(
row.into_iter()
.chain(features.then(|| required_features(svc)))
.collect::<Vec<_>>(),
);
}
println!("{table}");
println!("\n{} service(s)", services.len());
}
_ => {
println!("No services deployed yet.");
println!("Use `cufflink deploy` to deploy your first service.");
}
}
Ok(())
}
#[cfg(test)]
mod feature_tests {
use super::required_features;
use serde_json::json;
#[test]
fn required_features_are_listed_or_dashed() {
assert_eq!(
required_features(
&json!({"required_platform_features": ["route_posture", "scoped_permissions"]})
),
"route_posture,scoped_permissions"
);
assert_eq!(
required_features(&json!({"required_platform_features": []})),
"-"
);
assert_eq!(required_features(&json!({"name": "old"})), "-");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn listing() -> Value {
json!({"services": [
{"id": "11", "name": "reports", "mode": "wasm"},
{"id": "22", "name": "ext-google", "mode": "native"},
]})
}
#[test]
fn the_listing_names_a_service_by_its_id_whatever_its_mode() {
assert_eq!(listed(&listing(), "ext-google").expect("listed"), "22");
assert_eq!(listed(&listing(), "reports").expect("listed"), "11");
assert_eq!(
listed(&listing(), "gone")
.expect_err("unlisted")
.to_string(),
"Service 'gone' not found"
);
}
#[test]
fn delete_and_status_requests_name_the_service() {
assert_eq!(
delete_request("https://platform.test", "22", false),
(
Method::DELETE,
"https://platform.test/api/services/22".into()
)
);
assert_eq!(
delete_request("https://platform.test", "22", true),
(
Method::DELETE,
"https://platform.test/api/services/22?force=true".into()
)
);
assert_eq!(
status_request("https://platform.test", "22", "inactive"),
(
Method::PATCH,
"https://platform.test/api/services/22/status".into(),
json!({"status": "inactive"})
)
);
}
}