use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_context: Option<String>,
#[serde(default)]
pub contexts: BTreeMap<String, ContextEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub registries: BTreeMap<String, RegistryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_registry: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RegistryEntry {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_command: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub insecure_skip_tls_verify: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContextEntry {
pub server: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_command: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub insecure_skip_tls_verify: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordSource {
None,
Flag,
Command,
Stored,
}
impl PasswordSource {
pub fn describe(self) -> &'static str {
match self {
Self::None => "none",
Self::Flag => "--password / HEYCTL_PASSWORD",
Self::Command => "password_command",
Self::Stored => "stored in the config file",
}
}
pub fn describe_api_key(self) -> &'static str {
match self {
Self::None => "none",
Self::Flag => "--api-key / HEYCTL_ART_API_KEY",
Self::Command => "api_key_command",
Self::Stored => "stored in the config file",
}
}
pub fn describe_token(self) -> &'static str {
match self {
Self::None => "none",
Self::Flag => "--token / HEYCTL_TOKEN",
Self::Command => "token_command",
Self::Stored => "stored in the config file",
}
}
}
#[derive(Debug, Clone)]
pub struct Endpoint {
pub name: String,
pub server: String,
pub user: Option<String>,
pub password: Option<String>,
pub password_source: PasswordSource,
pub token: Option<String>,
pub token_source: PasswordSource,
pub insecure_skip_tls_verify: bool,
}
impl Config {
pub fn path(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
if let Some(dir) = dirs::config_dir() {
return Ok(dir.join("heyctl").join("config.json"));
}
let home = dirs::home_dir().context("no config directory and no home directory")?;
Ok(home.join(".heyctl").join("config.json"))
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) if text.trim().is_empty() => Ok(Self::default()),
Ok(text) => serde_json::from_str(&text)
.with_context(|| format!("parsing the config file {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
restrict(parent, 0o700)?;
}
let mut text = serde_json::to_string_pretty(self)?;
text.push('\n');
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
restrict(path, 0o600)?;
Ok(())
}
pub fn resolve(&self, requested: Option<&str>) -> Result<Option<(String, ContextEntry)>> {
if let Some(name) = requested {
let entry = self
.contexts
.get(name)
.with_context(|| format!("no context named {name:?} — `heyctl config get-contexts` lists them"))?;
return Ok(Some((name.to_string(), entry.clone())));
}
if let Some(name) = &self.current_context
&& let Some(entry) = self.contexts.get(name)
{
return Ok(Some((name.clone(), entry.clone())));
}
if self.contexts.len() == 1 {
let (name, entry) = self.contexts.iter().next().expect("len == 1");
return Ok(Some((name.clone(), entry.clone())));
}
if self.current_context.is_some() {
bail!(
"current context {:?} is not in the config file — pick one with \
`heyctl config use-context`",
self.current_context.as_deref().unwrap_or_default()
);
}
Ok(None)
}
pub fn resolve_registry(&self, requested: Option<&str>) -> Result<Option<(String, RegistryEntry)>> {
if let Some(name) = requested {
let entry = self.registries.get(name).with_context(|| {
format!("no registry named {name:?} — `heyctl artifact registries` lists them")
})?;
return Ok(Some((name.to_string(), entry.clone())));
}
if let Some(name) = &self.current_registry
&& let Some(entry) = self.registries.get(name)
{
return Ok(Some((name.clone(), entry.clone())));
}
if self.registries.len() == 1 {
let (name, entry) = self.registries.iter().next().expect("len == 1");
return Ok(Some((name.clone(), entry.clone())));
}
if self.current_registry.is_some() {
bail!(
"current registry {:?} is not in the config file — pick one with \
`heyctl artifact use`",
self.current_registry.as_deref().unwrap_or_default()
);
}
Ok(None)
}
}
#[derive(Debug, Clone)]
pub struct RegistryEndpoint {
pub name: String,
pub url: String,
pub api_key: Option<String>,
pub api_key_source: PasswordSource,
pub insecure_skip_tls_verify: bool,
}
pub fn resolve_registry_endpoint(
config: &Config,
registry: Option<&str>,
url: Option<&str>,
api_key: Option<&str>,
insecure: bool,
) -> Result<RegistryEndpoint> {
let resolved = config.resolve_registry(registry)?;
let (name, entry) = match resolved {
Some((n, e)) => (n, e),
None if url.is_some() => ("(none)".to_string(), RegistryEntry::default()),
None => bail!(
"no artifact store configured — run `heyctl artifact login <url>` first, \
or pass --registry-url"
),
};
let (api_key, api_key_source) = match api_key {
Some(k) => (Some(k.to_string()), PasswordSource::Flag),
None => match &entry.api_key_command {
Some(cmd) => (Some(run_password_command(cmd)?), PasswordSource::Command),
None => match &entry.api_key {
Some(k) => (Some(k.clone()), PasswordSource::Stored),
None => (None, PasswordSource::None),
},
},
};
Ok(RegistryEndpoint {
name,
url: url
.map(str::to_string)
.or_else(|| (!entry.url.is_empty()).then(|| entry.url.clone()))
.ok_or_else(|| anyhow::anyhow!("the stored registry has no url"))?,
api_key,
api_key_source,
insecure_skip_tls_verify: insecure || entry.insecure_skip_tls_verify,
})
}
pub fn resolve_endpoint(
config: &Config,
context: Option<&str>,
server: Option<&str>,
user: Option<&str>,
password: Option<&str>,
token: Option<&str>,
insecure: bool,
) -> Result<Endpoint> {
let resolved = config.resolve(context)?;
let (name, entry) = match resolved {
Some((n, e)) => (n, e),
None => ("(none)".to_string(), ContextEntry::default()),
};
let (password, password_source) = match password {
Some(p) => (Some(p.to_string()), PasswordSource::Flag),
None => match &entry.password_command {
Some(cmd) => (Some(run_password_command(cmd)?), PasswordSource::Command),
None => match &entry.password {
Some(p) => (Some(p.clone()), PasswordSource::Stored),
None => (None, PasswordSource::None),
},
},
};
let (token, token_source) = match token {
Some(t) => (Some(t.to_string()), PasswordSource::Flag),
None => match &entry.token_command {
Some(cmd) => (Some(run_password_command(cmd)?), PasswordSource::Command),
None => match &entry.token {
Some(t) => (Some(t.clone()), PasswordSource::Stored),
None => (None, PasswordSource::None),
},
},
};
Ok(Endpoint {
name,
server: server
.map(str::to_string)
.or_else(|| (!entry.server.is_empty()).then(|| entry.server.clone()))
.unwrap_or_else(|| crate::cmd::DEFAULT_SERVER.to_string()),
user: user.map(str::to_string).or(entry.user),
password,
password_source,
token,
token_source,
insecure_skip_tls_verify: insecure || entry.insecure_skip_tls_verify,
})
}
fn run_password_command(cmd: &str) -> Result<String> {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.with_context(|| format!("running password_command: {cmd}"))?;
if !out.status.success() {
bail!(
"password_command failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
let password = String::from_utf8(out.stdout)
.context("password_command produced output that is not UTF-8")?;
Ok(password.trim_end_matches(['\n', '\r']).to_string())
}
#[cfg(unix)]
fn restrict(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("setting {mode:o} on {}", path.display()))
}
#[cfg(not(unix))]
fn restrict(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(names: &[&str], current: Option<&str>) -> Config {
Config {
current_context: current.map(str::to_string),
contexts: names
.iter()
.map(|n| {
(
n.to_string(),
ContextEntry {
server: format!("http://{n}:9090"),
user: Some("admin".into()),
password: Some("pw".into()),
..Default::default()
},
)
})
.collect(),
..Default::default()
}
}
#[test]
fn a_token_outranks_the_pair_and_follows_the_same_ladder() {
let mut cfg = cfg_with(&["lb"], None);
cfg.contexts.get_mut("lb").unwrap().token = Some("heyo_api_stored".into());
let ep = resolve_endpoint(&cfg, None, None, None, None, None, false).unwrap();
assert_eq!(ep.token.as_deref(), Some("heyo_api_stored"));
assert_eq!(ep.token_source, PasswordSource::Stored);
assert_eq!(ep.password.as_deref(), Some("pw"));
let ep = resolve_endpoint(&cfg, None, None, None, None, Some("heyo_api_flag"), false).unwrap();
assert_eq!(ep.token.as_deref(), Some("heyo_api_flag"));
assert_eq!(ep.token_source, PasswordSource::Flag);
cfg.contexts.get_mut("lb").unwrap().token_command = Some("printf heyo_api_cmd".into());
let ep = resolve_endpoint(&cfg, None, None, None, None, None, false).unwrap();
assert_eq!(ep.token.as_deref(), Some("heyo_api_cmd"));
assert_eq!(ep.token_source, PasswordSource::Command);
let ep = resolve_endpoint(&Config::default(), None, None, None, None, None, false).unwrap();
assert!(ep.token.is_none());
assert_eq!(ep.token_source, PasswordSource::None);
}
fn cfg_with_registries(names: &[&str], current: Option<&str>) -> Config {
Config {
current_registry: current.map(str::to_string),
registries: names
.iter()
.map(|n| {
(
n.to_string(),
RegistryEntry {
url: format!("http://{n}:8080"),
api_key: Some("k".into()),
..Default::default()
},
)
})
.collect(),
..Default::default()
}
}
#[test]
fn a_lone_registry_is_used_without_being_selected() {
let cfg = cfg_with_registries(&["store"], None);
let ep = resolve_registry_endpoint(&cfg, None, None, None, false).unwrap();
assert_eq!(ep.name, "store");
assert_eq!(ep.url, "http://store:8080");
assert_eq!(ep.api_key_source, PasswordSource::Stored);
}
#[test]
fn no_registry_is_an_error_rather_than_a_guessed_url() {
assert!(resolve_registry_endpoint(&Config::default(), None, None, None, false).is_err());
let ep = resolve_registry_endpoint(
&Config::default(),
None,
Some("http://art:8080"),
Some("key"),
false,
)
.unwrap();
assert_eq!(ep.url, "http://art:8080");
assert_eq!(ep.api_key_source, PasswordSource::Flag);
}
#[test]
fn an_unknown_registry_is_an_error() {
assert!(cfg_with_registries(&["prod"], None).resolve_registry(Some("dev")).is_err());
}
#[test]
fn registries_and_contexts_are_stored_side_by_side_without_colliding() {
let mut cfg = cfg_with(&["prod"], Some("prod"));
cfg.registries = cfg_with_registries(&["store"], None).registries;
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.contexts["prod"].server, "http://prod:9090");
assert_eq!(back.registries["store"].url, "http://store:8080");
}
#[test]
fn a_config_written_before_registries_existed_still_parses() {
let old = r#"{"current_context":"prod","contexts":{"prod":{"server":"http://prod:9090"}}}"#;
let cfg: Config = serde_json::from_str(old).unwrap();
assert!(cfg.registries.is_empty());
assert!(cfg.current_registry.is_none());
}
#[test]
fn a_lone_context_is_used_without_being_selected() {
let cfg = cfg_with(&["prod"], None);
let (name, _) = cfg.resolve(None).unwrap().unwrap();
assert_eq!(name, "prod");
}
#[test]
fn an_empty_config_resolves_to_nothing_rather_than_failing() {
assert!(Config::default().resolve(None).unwrap().is_none());
}
#[test]
fn flags_outrank_the_stored_context() {
let cfg = cfg_with(&["prod"], Some("prod"));
let ep = resolve_endpoint(&cfg, None, Some("http://other:1"), None, Some("flag"), None, false)
.unwrap();
assert_eq!(ep.server, "http://other:1");
assert_eq!(ep.password.as_deref(), Some("flag"));
assert_eq!(ep.password_source, PasswordSource::Flag);
assert_eq!(ep.user.as_deref(), Some("admin"), "not overridden, so kept");
}
#[test]
fn no_config_at_all_still_points_at_a_local_app_lb() {
let ep = resolve_endpoint(&Config::default(), None, None, None, None, None, false).unwrap();
assert_eq!(ep.server, crate::cmd::DEFAULT_SERVER);
assert_eq!(ep.password_source, PasswordSource::None);
}
#[test]
fn an_unknown_context_is_an_error() {
assert!(cfg_with(&["prod"], None).resolve(Some("staging")).is_err());
}
}