use anyhow::{Context, Result};
use sqlx::PgPool;
use crate::auth::hash_token;
#[derive(Debug, PartialEq, Eq)]
pub struct AgentSeed {
pub email: String,
pub token: String,
}
pub fn parse_seeds(raw: &str) -> Result<Vec<AgentSeed>> {
let mut seeds = Vec::new();
for entry in raw.split(',').map(str::trim).filter(|entry| !entry.is_empty()) {
let (email, token) = entry
.split_once(':')
.with_context(|| format!("KASL_AGENTS entry `{entry}` is not `email:token`"))?;
let (email, token) = (email.trim(), token.trim());
if email.is_empty() || token.is_empty() {
anyhow::bail!("KASL_AGENTS entry `{entry}` has an empty email or token");
}
seeds.push(AgentSeed {
email: email.to_string(),
token: token.to_string(),
});
}
Ok(seeds)
}
pub async fn apply_seeds(pool: &PgPool, seeds: &[AgentSeed]) -> Result<()> {
for seed in seeds {
let mut tx = pool.begin().await?;
let display_name = seed.email.split('@').next().unwrap_or(&seed.email);
let user_id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO users (email, display_name) VALUES ($1, $2)
ON CONFLICT (lower(email)) DO UPDATE SET active = true
RETURNING id",
)
.bind(&seed.email)
.bind(display_name)
.fetch_one(&mut *tx)
.await
.with_context(|| format!("failed to provision the user for {}", seed.email))?;
sqlx::query(
"INSERT INTO agents (user_id, name, token_hash, revoked_at) VALUES ($1, 'seeded', $2, NULL)
ON CONFLICT (token_hash) DO NOTHING",
)
.bind(user_id)
.bind(hash_token(&seed.token))
.execute(&mut *tx)
.await
.with_context(|| format!("failed to provision the agent for {}", seed.email))?;
sqlx::query("UPDATE agents SET revoked_at = now() WHERE user_id = $1 AND name = 'seeded' AND token_hash <> $2 AND revoked_at IS NULL")
.bind(user_id)
.bind(hash_token(&seed.token))
.execute(&mut *tx)
.await
.with_context(|| format!("failed to revoke the previous token for {}", seed.email))?;
tx.commit().await?;
}
if !seeds.is_empty() {
tracing::info!(agents = seeds.len(), "provisioned agents from KASL_AGENTS");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_several_entries() {
let seeds = parse_seeds("alice@example.test:token-a, bob@example.test:token-b").unwrap();
assert_eq!(
seeds,
vec![
AgentSeed {
email: "alice@example.test".into(),
token: "token-a".into()
},
AgentSeed {
email: "bob@example.test".into(),
token: "token-b".into()
},
]
);
}
#[test]
fn an_absent_variable_provisions_nothing() {
assert!(parse_seeds("").unwrap().is_empty());
assert!(parse_seeds(" , ").unwrap().is_empty());
}
#[test]
fn a_colon_inside_the_token_survives() {
let seeds = parse_seeds("alice@example.test:ab:cd").unwrap();
assert_eq!(seeds[0].token, "ab:cd", "only the first colon separates");
}
#[test]
fn malformed_entries_name_themselves() {
let error = parse_seeds("alice@example.test").unwrap_err().to_string();
assert!(error.contains("alice@example.test"), "the message should quote the entry: {error}");
assert!(parse_seeds("alice@example.test:").is_err(), "an empty token is not usable");
assert!(parse_seeds(":token").is_err(), "an agent with no user has nobody to report for");
}
}