use crate::config::CliConfig;
use crate::project_config::ProjectConfig;
use std::collections::HashMap;
use std::process::Command;
async fn get_service_id(config: &CliConfig) -> eyre::Result<String> {
let project = ProjectConfig::find_and_load()?;
let mode = project
.as_ref()
.and_then(|p| p.service.mode.as_deref())
.unwrap_or("rust");
let service_name = if mode == "web" {
project
.as_ref()
.and_then(|p| p.service.name.as_deref())
.ok_or_else(|| eyre::eyre!("Web mode requires [service].name in Cufflink.toml"))?
.to_string()
} else {
let output = Command::new("cargo")
.args(["run", "--", "--emit-manifest"])
.output()?;
if !output.status.success() {
eyre::bail!("Failed to build service. Run from a cufflink service directory.");
}
let stdout = String::from_utf8(output.stdout)?;
let manifest: serde_json::Value = serde_json::from_str(stdout.trim())?;
manifest["name"]
.as_str()
.ok_or_else(|| eyre::eyre!("No service name in manifest"))?
.to_string()
};
let service_name = &service_name;
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
let services: serde_json::Value = resp.json().await?;
let service = services["services"]
.as_array()
.and_then(|arr| {
arr.iter()
.find(|s| s["name"].as_str() == Some(service_name))
})
.ok_or_else(|| eyre::eyre!("Service '{}' not found on platform", service_name))?;
Ok(service["id"]
.as_str()
.ok_or_else(|| eyre::eyre!("Service has no ID"))?
.to_string())
}
pub async fn list(env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
let service_id = get_service_id(&config).await?;
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services/{}/config", config.api_url, service_id),
)
.send()
.await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
if let Some(configs) = body["configs"].as_array() {
if configs.is_empty() {
println!("No configuration values set.");
} else {
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, ' ');
table.set_header(vec!["KEY", "VALUE", "SECRET"]);
for c in configs {
let secret = if c["is_secret"].as_bool() == Some(true) {
"yes"
} else {
"no"
};
table.add_row(vec![
c["key"].as_str().unwrap_or(""),
c["value"].as_str().unwrap_or(""),
secret,
]);
}
println!("{table}");
}
}
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to list config ({}): {}", status, body);
}
Ok(())
}
pub async fn set(key: &str, value: &str, is_secret: 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 service_id = get_service_id(&config).await?;
let payload = serde_json::json!({
"key": key,
"value": value,
"is_secret": is_secret,
});
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::PUT,
&format!("{}/api/services/{}/config", config.api_url, service_id),
)
.json(&payload)
.send()
.await?;
if resp.status().is_success() {
println!("Config '{}' set successfully", key);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to set config ({}): {}", status, body);
}
Ok(())
}
pub async fn delete(key: &str, env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
let service_id = get_service_id(&config).await?;
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::DELETE,
&format!(
"{}/api/services/{}/config/{}",
config.api_url, service_id, key
),
)
.send()
.await?;
if resp.status().is_success() {
println!("Config '{}' deleted", key);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to delete config ({}): {}", status, body);
}
Ok(())
}
pub async fn sync(env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::load_with_env(env)?;
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
let service_id = get_service_id(&config).await?;
let project =
ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;
let env_name = env
.map(|s| s.to_string())
.or_else(|| project.service.default_env.clone())
.ok_or_else(|| eyre::eyre!("No environment specified"))?;
let env_config = project.get_env(&env_name)?;
sync_to_platform(
&config,
&service_id,
&env_config.config,
&env_config.secrets,
&project,
&env_name,
)
.await
}
pub async fn sync_to_platform(
cli_config: &CliConfig,
service_id: &str,
configs: &HashMap<String, String>,
secrets: &HashMap<String, String>,
project: &ProjectConfig,
env_name: &str,
) -> eyre::Result<()> {
let client = cli_config.http_client();
let config_url = format!("{}/api/services/{}/config", cli_config.api_url, service_id);
let mut synced = 0;
for (key, value) in configs {
let payload = serde_json::json!({
"key": key,
"value": value,
"is_secret": false,
});
let resp = cli_config
.auth_request(&client, reqwest::Method::PUT, &config_url)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to sync config '{}' ({}): {}", key, status, body);
}
synced += 1;
}
let resolved_secrets = resolve_secret_refs(project, env_name, secrets)?;
let mut secrets_synced = 0;
for (config_key, value) in &resolved_secrets {
let payload = serde_json::json!({
"key": config_key,
"value": value,
"is_secret": true,
});
let resp = cli_config
.auth_request(&client, reqwest::Method::PUT, &config_url)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!(
"Failed to sync secret '{}' ({}): {}",
config_key,
status,
body
);
}
secrets_synced += 1;
}
if synced > 0 || secrets_synced > 0 {
println!(
" Synced {} config(s) and {} secret(s)",
synced, secrets_synced
);
}
Ok(())
}
fn resolve_secret_refs(
project: &ProjectConfig,
env_name: &str,
secret_refs: &HashMap<String, String>,
) -> eyre::Result<HashMap<String, String>> {
if secret_refs.is_empty() {
return Ok(HashMap::new());
}
let store = super::secrets_cmd::load_store(project, env_name)?;
let mut out = HashMap::with_capacity(secret_refs.len());
for (config_key, store_name) in secret_refs {
let value: String = store.get(store_name).map_err(|e| {
eyre::eyre!(
"Secret '{}' not found in securestore for '{}': {}",
store_name,
env_name,
e
)
})?;
out.insert(config_key.clone(), value);
}
Ok(out)
}
pub fn resolve_local_envs(
project: &ProjectConfig,
env_name: &str,
) -> eyre::Result<HashMap<String, String>> {
let env = project.get_env(env_name)?;
let mut out = env.config.clone();
out.extend(resolve_secret_refs(project, env_name, &env.secrets)?);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project_config::EnvironmentConfig;
use securestore::{KeySource, SecretsManager};
use tempfile::TempDir;
fn make_project_with_vault(env_name: &str) -> (TempDir, ProjectConfig) {
let tmp = tempfile::tempdir().unwrap();
let project_dir = tmp.path().to_path_buf();
let secrets_dir = project_dir.join("secrets");
std::fs::create_dir_all(&secrets_dir).unwrap();
let key_path = secrets_dir.join(format!("{}.key", env_name));
let vault_path = secrets_dir.join(format!("{}.json", env_name));
let store_for_key = SecretsManager::new(KeySource::Csprng).unwrap();
store_for_key.export_key(&key_path).unwrap();
let mut store = SecretsManager::new(KeySource::Path(&key_path)).unwrap();
store.set("google_maps:api_key", "AIzaSyTEST");
store.set("postmark:api_key", "pm-test-token");
store.save_as(&vault_path).unwrap();
let project = ProjectConfig {
service: Default::default(),
environments: HashMap::new(),
project_dir,
};
(tmp, project)
}
fn env_with(config: &[(&str, &str)], secrets: &[(&str, &str)]) -> EnvironmentConfig {
EnvironmentConfig {
api_url: "http://localhost:8080".to_string(),
tenant: "default".to_string(),
api_key_env: None,
api_key: None,
keycloak_url: None,
keycloak_realm: None,
keycloak_client_id: None,
config: config
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
secrets: secrets
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn resolves_config_only_passes_values_through() {
let (_tmp, mut project) = make_project_with_vault("staging");
project.environments.insert(
"staging".to_string(),
env_with(&[("NEXT_PUBLIC_API_URL", "https://api.example")], &[]),
);
let envs = resolve_local_envs(&project, "staging").unwrap();
assert_eq!(
envs.get("NEXT_PUBLIC_API_URL").map(String::as_str),
Some("https://api.example")
);
}
#[test]
fn resolves_secrets_against_local_vault() {
let (_tmp, mut project) = make_project_with_vault("staging");
project.environments.insert(
"staging".to_string(),
env_with(
&[],
&[("NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", "google_maps:api_key")],
),
);
let envs = resolve_local_envs(&project, "staging").unwrap();
assert_eq!(
envs.get("NEXT_PUBLIC_GOOGLE_MAPS_API_KEY")
.map(String::as_str),
Some("AIzaSyTEST")
);
}
#[test]
fn resolves_mixed_config_and_secrets() {
let (_tmp, mut project) = make_project_with_vault("staging");
project.environments.insert(
"staging".to_string(),
env_with(
&[("NEXT_PUBLIC_API_URL", "https://api.example")],
&[
("NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", "google_maps:api_key"),
("POSTMARK_API_KEY", "postmark:api_key"),
],
),
);
let envs = resolve_local_envs(&project, "staging").unwrap();
assert_eq!(envs.len(), 3);
assert_eq!(envs["NEXT_PUBLIC_API_URL"], "https://api.example");
assert_eq!(envs["NEXT_PUBLIC_GOOGLE_MAPS_API_KEY"], "AIzaSyTEST");
assert_eq!(envs["POSTMARK_API_KEY"], "pm-test-token");
}
#[test]
fn errors_when_referenced_secret_missing_from_vault() {
let (_tmp, mut project) = make_project_with_vault("staging");
project.environments.insert(
"staging".to_string(),
env_with(&[], &[("MISSING_VAR", "missing:secret")]),
);
let err = resolve_local_envs(&project, "staging").unwrap_err();
assert!(err.to_string().contains("missing:secret"));
assert!(err.to_string().contains("staging"));
}
#[test]
fn errors_for_unknown_environment() {
let (_tmp, project) = make_project_with_vault("staging");
let err = resolve_local_envs(&project, "nope").unwrap_err();
assert!(err.to_string().contains("nope"));
}
}