use anyhow::{Context, Result};
use crate::secrets;
const ENV_TOKEN: &str = "KANADE_NATS_TOKEN";
const REG_VALUE: &str = "NatsToken";
const REG_SHARED_SUBKEY: &str = r"SOFTWARE\kanade\agent";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NatsRole {
Agent,
Backend,
Cli,
}
impl NatsRole {
pub fn as_str(self) -> &'static str {
match self {
NatsRole::Agent => "agent",
NatsRole::Backend => "backend",
NatsRole::Cli => "cli",
}
}
fn reg_subkey(self) -> String {
format!(r"SOFTWARE\kanade\{}", self.as_str())
}
}
fn resolve_token_with(
role: NatsRole,
read_reg: impl Fn(&str, &str) -> Option<String>,
env: Option<String>,
) -> Option<String> {
if let Some(t) = read_reg(&role.reg_subkey(), REG_VALUE) {
return Some(t);
}
if let Some(t) = read_reg(REG_SHARED_SUBKEY, REG_VALUE) {
return Some(t);
}
env.filter(|t| !t.is_empty())
}
fn resolve_token(role: NatsRole) -> Option<String> {
resolve_token_with(
role,
secrets::read_hklm_value,
std::env::var(ENV_TOKEN).ok(),
)
}
pub async fn connect(role: NatsRole, url: &str) -> Result<async_nats::Client> {
connect_inner(
role,
url,
None::<fn(async_nats::Event) -> std::future::Ready<()>>,
)
.await
}
pub async fn connect_with_event_callback<F, Fut>(
role: NatsRole,
url: &str,
cb: F,
) -> Result<async_nats::Client>
where
F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
{
connect_inner(role, url, Some(cb)).await
}
async fn connect_inner<F, Fut>(
role: NatsRole,
url: &str,
cb: Option<F>,
) -> Result<async_nats::Client>
where
F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
{
let opts = async_nats::ConnectOptions::new()
.retry_on_initial_connect()
.name(format!("kanade-{}", role.as_str()));
let opts = match resolve_token(role) {
Some(token) => opts.token(token),
None => opts,
};
let opts = match cb {
Some(cb) => opts.event_callback(cb),
None => opts,
};
opts.connect(url)
.await
.with_context(|| format!("connect to NATS at {url}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn reg(entries: &[(&str, &str)]) -> impl Fn(&str, &str) -> Option<String> {
let map: HashMap<String, String> = entries
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |subkey: &str, value: &str| map.get(&format!(r"{subkey}\{value}")).cloned()
}
#[test]
fn role_subkeys_are_distinct_and_agent_matches_the_shared_path() {
assert_eq!(NatsRole::Backend.reg_subkey(), r"SOFTWARE\kanade\backend");
assert_eq!(NatsRole::Cli.reg_subkey(), r"SOFTWARE\kanade\cli");
assert_eq!(NatsRole::Agent.reg_subkey(), REG_SHARED_SUBKEY);
}
#[test]
fn an_unmigrated_fleet_keeps_presenting_the_shared_token() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
for role in [NatsRole::Agent, NatsRole::Backend, NatsRole::Cli] {
assert_eq!(
resolve_token_with(role, ®istry, None).as_deref(),
Some("shared"),
"{role:?} must keep working before its own key is provisioned"
);
}
}
#[test]
fn a_role_key_wins_over_the_shared_one() {
let registry = reg(&[
(r"SOFTWARE\kanade\agent\NatsToken", "shared"),
(r"SOFTWARE\kanade\backend\NatsToken", "backend-only"),
]);
assert_eq!(
resolve_token_with(NatsRole::Backend, ®istry, None).as_deref(),
Some("backend-only")
);
assert_eq!(
resolve_token_with(NatsRole::Cli, ®istry, None).as_deref(),
Some("shared")
);
}
#[test]
fn removing_a_role_key_falls_back_rather_than_failing() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
assert_eq!(
resolve_token_with(NatsRole::Backend, ®istry, None).as_deref(),
Some("shared")
);
}
#[test]
fn the_registry_outranks_the_environment() {
let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
assert_eq!(
resolve_token_with(NatsRole::Agent, ®istry, Some("from-env".into())).as_deref(),
Some("shared")
);
}
#[test]
fn the_environment_serves_hosts_with_no_registry_at_all() {
let empty = reg(&[]);
assert_eq!(
resolve_token_with(NatsRole::Cli, &empty, Some("from-env".into())).as_deref(),
Some("from-env")
);
assert_eq!(
resolve_token_with(NatsRole::Cli, &empty, Some(String::new())),
None
);
assert_eq!(resolve_token_with(NatsRole::Cli, &empty, None), None);
}
}