add sdk layer: vault client, token store, gitea API
vault.rs — OpenBao client with kubernetes auth, KV v2 operations, automatic token refresh on 403. proper error handling on all paths. tokens.rs — vault-backed token storage with expiry validation. get_valid returns Result<Option> to distinguish vault errors from missing tokens. username mappings stay in sqlite (not secrets). gitea.rs — typed gitea API v1 wrapper with per-user PAT auto-provisioning via admin API. username discovery by direct match or email search. URL-encoded query params. handles 400 and 422 token name conflicts with delete+retry.
This commit is contained in:
732
src/sdk/gitea.rs
Normal file
732
src/sdk/gitea.rs
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use reqwest::Client as HttpClient;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use super::tokens::TokenStore;
|
||||||
|
|
||||||
|
const SERVICE: &str = "gitea";
|
||||||
|
const TOKEN_NAME: &str = "sol-agent";
|
||||||
|
const TOKEN_SCOPES: &[&str] = &[
|
||||||
|
"read:issue",
|
||||||
|
"write:issue",
|
||||||
|
"read:repository",
|
||||||
|
"read:user",
|
||||||
|
"read:organization",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct GiteaClient {
|
||||||
|
base_url: String,
|
||||||
|
admin_username: String,
|
||||||
|
admin_password: String,
|
||||||
|
http: HttpClient,
|
||||||
|
token_store: Arc<TokenStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RepoSummary {
|
||||||
|
pub full_name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub html_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub open_issues_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stars_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub private: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Repo {
|
||||||
|
pub full_name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub html_url: String,
|
||||||
|
pub default_branch: String,
|
||||||
|
pub open_issues_count: u32,
|
||||||
|
pub stars_count: u32,
|
||||||
|
pub forks_count: u32,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub private: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Issue {
|
||||||
|
pub number: u64,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
pub state: String,
|
||||||
|
pub html_url: String,
|
||||||
|
pub user: UserRef,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: Vec<Label>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PullRequest {
|
||||||
|
pub number: u64,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
pub state: String,
|
||||||
|
pub html_url: String,
|
||||||
|
pub user: UserRef,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mergeable: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserRef {
|
||||||
|
pub login: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Label {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FileContent {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub file_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaUser {
|
||||||
|
login: String,
|
||||||
|
#[serde(default)]
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaUserSearchResult {
|
||||||
|
data: Vec<GiteaUser>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GiteaToken {
|
||||||
|
sha1: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Implementation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl GiteaClient {
|
||||||
|
pub fn new(
|
||||||
|
base_url: String,
|
||||||
|
admin_username: String,
|
||||||
|
admin_password: String,
|
||||||
|
token_store: Arc<TokenStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
admin_username,
|
||||||
|
admin_password,
|
||||||
|
http: HttpClient::new(),
|
||||||
|
token_store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the Gitea username for a Matrix localpart.
|
||||||
|
///
|
||||||
|
/// 1. Check cached mapping in SQLite
|
||||||
|
/// 2. Try direct username match via admin API
|
||||||
|
/// 3. Search by email as fallback
|
||||||
|
pub async fn resolve_username(&self, localpart: &str) -> Result<String, String> {
|
||||||
|
// Check cached mapping
|
||||||
|
if let Some(username) = self.token_store.resolve_username(localpart, SERVICE) {
|
||||||
|
return Ok(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct username match
|
||||||
|
let url = format!("{}/api/v1/users/{}", self.base_url, localpart);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&self.admin_username, Some(&self.admin_password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to query Gitea: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
let user: GiteaUser = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse Gitea user: {e}"))?;
|
||||||
|
info!(localpart, gitea_username = user.login.as_str(), "Direct username match");
|
||||||
|
self.token_store
|
||||||
|
.set_username(localpart, SERVICE, &user.login);
|
||||||
|
return Ok(user.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by email
|
||||||
|
let search_url = format!(
|
||||||
|
"{}/api/v1/users/search?q={}@sunbeam.pt",
|
||||||
|
self.base_url, localpart
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&search_url)
|
||||||
|
.basic_auth(&self.admin_username, Some(&self.admin_password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to search Gitea users: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Gitea user search failed (HTTP {status}): {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: GiteaUserSearchResult = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse search results: {e}"))?;
|
||||||
|
|
||||||
|
// Find user whose email matches
|
||||||
|
let target_email = format!("{localpart}@sunbeam.pt");
|
||||||
|
if let Some(user) = result
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.email.eq_ignore_ascii_case(&target_email))
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
localpart,
|
||||||
|
gitea_username = user.login.as_str(),
|
||||||
|
"Email-based match"
|
||||||
|
);
|
||||||
|
self.token_store
|
||||||
|
.set_username(localpart, SERVICE, &user.login);
|
||||||
|
return Ok(user.login.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"no Gitea account found for '{localpart}' — log into Gitea once, \
|
||||||
|
or DM me: `sol link gitea <username>`"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the user has a valid PAT, creating one if needed.
|
||||||
|
/// Returns the token string.
|
||||||
|
pub async fn ensure_token(&self, localpart: &str) -> Result<String, String> {
|
||||||
|
// Check for existing valid token (propagate Vault errors)
|
||||||
|
match self.token_store.get_valid(localpart, SERVICE).await {
|
||||||
|
Ok(Some(token)) => return Ok(token),
|
||||||
|
Ok(None) => {} // No token, provision below
|
||||||
|
Err(e) => {
|
||||||
|
warn!(localpart, "Vault read failed, will provision new PAT: {e}");
|
||||||
|
// Fall through to provision — but log the Vault issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve username first
|
||||||
|
let username = self.resolve_username(localpart).await?;
|
||||||
|
|
||||||
|
// Create a new PAT via admin API
|
||||||
|
let token = self.create_user_pat(&username).await?;
|
||||||
|
if let Err(e) = self
|
||||||
|
.token_store
|
||||||
|
.put(localpart, SERVICE, &token, "pat", None, None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(localpart, "Failed to store PAT in Vault (token still works for this session): {e}");
|
||||||
|
}
|
||||||
|
info!(localpart, gitea_username = username.as_str(), "Provisioned Gitea PAT");
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PAT for a Gitea user via the admin API.
|
||||||
|
async fn create_user_pat(&self, username: &str) -> Result<String, String> {
|
||||||
|
let url = format!("{}/api/v1/users/{}/tokens", self.base_url, username);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": TOKEN_NAME,
|
||||||
|
"scopes": TOKEN_SCOPES,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.basic_auth(&self.admin_username, Some(&self.admin_password))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to create PAT: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 422 || resp.status().as_u16() == 400 {
|
||||||
|
// Token name already exists — delete and retry
|
||||||
|
debug!(username, status = resp.status().as_u16(), "Token name conflict, deleting old token");
|
||||||
|
self.delete_user_pat(username).await?;
|
||||||
|
return self.create_user_pat_inner(username, &url, &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Err(format!("Gitea user '{username}' does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("failed to create PAT (HTTP {status}): {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: GiteaToken = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse token response: {e}"))?;
|
||||||
|
|
||||||
|
Ok(token.sha1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user_pat_inner(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
url: &str,
|
||||||
|
body: &serde_json::Value,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(url)
|
||||||
|
.basic_auth(&self.admin_username, Some(&self.admin_password))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to create PAT (retry): {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!(
|
||||||
|
"failed to create PAT for '{username}' (HTTP {status}): {text}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: GiteaToken = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse token response: {e}"))?;
|
||||||
|
|
||||||
|
Ok(token.sha1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the sol-agent PAT for a user.
|
||||||
|
async fn delete_user_pat(&self, username: &str) -> Result<(), String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/users/{}/tokens/{}",
|
||||||
|
self.base_url, username, TOKEN_NAME
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.delete(&url)
|
||||||
|
.basic_auth(&self.admin_username, Some(&self.admin_password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to delete old PAT: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(()); // Already gone
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("failed to delete old PAT (HTTP {status}): {text}"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an authenticated GET request using the user's token.
|
||||||
|
/// On 401, invalidates token and retries once.
|
||||||
|
async fn authed_get(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<reqwest::Response, String> {
|
||||||
|
let token = self.ensure_token(localpart).await?;
|
||||||
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request failed: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 401 {
|
||||||
|
debug!(localpart, "Token rejected, re-provisioning");
|
||||||
|
self.token_store.delete(localpart, SERVICE).await;
|
||||||
|
let token = self.ensure_token(localpart).await?;
|
||||||
|
return self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request failed (retry): {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an authenticated POST request using the user's token.
|
||||||
|
/// On 401, invalidates token and retries once.
|
||||||
|
async fn authed_post(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
path: &str,
|
||||||
|
body: &serde_json::Value,
|
||||||
|
) -> Result<reqwest::Response, String> {
|
||||||
|
let token = self.ensure_token(localpart).await?;
|
||||||
|
let url = format!("{}{}", self.base_url, path);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request failed: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 401 {
|
||||||
|
debug!(localpart, "Token rejected, re-provisioning");
|
||||||
|
self.token_store.delete(localpart, SERVICE).await;
|
||||||
|
let token = self.ensure_token(localpart).await?;
|
||||||
|
return self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request failed (retry): {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API methods ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn list_repos(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
query: Option<&str>,
|
||||||
|
org: Option<&str>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<Vec<RepoSummary>, String> {
|
||||||
|
let param_str = {
|
||||||
|
let mut encoder = form_urlencoded::Serializer::new(String::new());
|
||||||
|
if let Some(q) = query {
|
||||||
|
encoder.append_pair("q", q);
|
||||||
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
encoder.append_pair("limit", &limit.to_string());
|
||||||
|
}
|
||||||
|
let encoded = encoder.finish();
|
||||||
|
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = if let Some(org) = org {
|
||||||
|
format!("/api/v1/orgs/{org}/repos{param_str}")
|
||||||
|
} else {
|
||||||
|
format!("/api/v1/repos/search{param_str}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = self.authed_get(localpart, &path).await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("list repos failed: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The search endpoint wraps results in {"data": [...]}
|
||||||
|
if org.is_none() {
|
||||||
|
let wrapper: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("parse error: {e}"))?;
|
||||||
|
let repos: Vec<RepoSummary> = serde_json::from_value(
|
||||||
|
wrapper.get("data").cloned().unwrap_or(serde_json::json!([])),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("parse error: {e}"))?;
|
||||||
|
Ok(repos)
|
||||||
|
} else {
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_repo(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
) -> Result<Repo, String> {
|
||||||
|
let resp = self
|
||||||
|
.authed_get(localpart, &format!("/api/v1/repos/{owner}/{repo}"))
|
||||||
|
.await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("get repo failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_issues(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
state: Option<&str>,
|
||||||
|
labels: Option<&str>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<Vec<Issue>, String> {
|
||||||
|
let query_string = {
|
||||||
|
let mut encoder = form_urlencoded::Serializer::new(String::new());
|
||||||
|
encoder.append_pair("type", "issues");
|
||||||
|
if let Some(s) = state {
|
||||||
|
encoder.append_pair("state", s);
|
||||||
|
}
|
||||||
|
if let Some(l) = labels {
|
||||||
|
encoder.append_pair("labels", l);
|
||||||
|
}
|
||||||
|
if let Some(n) = limit {
|
||||||
|
encoder.append_pair("limit", &n.to_string());
|
||||||
|
}
|
||||||
|
encoder.finish()
|
||||||
|
};
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{repo}/issues?{query_string}");
|
||||||
|
let resp = self.authed_get(localpart, &path).await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("list issues failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_issue(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
number: u64,
|
||||||
|
) -> Result<Issue, String> {
|
||||||
|
let resp = self
|
||||||
|
.authed_get(
|
||||||
|
localpart,
|
||||||
|
&format!("/api/v1/repos/{owner}/{repo}/issues/{number}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("get issue failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
title: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
labels: Option<&[String]>,
|
||||||
|
) -> Result<Issue, String> {
|
||||||
|
let mut json = serde_json::json!({ "title": title });
|
||||||
|
if let Some(b) = body {
|
||||||
|
json["body"] = serde_json::json!(b);
|
||||||
|
}
|
||||||
|
if let Some(l) = labels {
|
||||||
|
// Gitea expects label IDs, but we'll accept names and let the API handle it.
|
||||||
|
// For simplicity, we pass labels as-is — users can use IDs.
|
||||||
|
json["labels"] = serde_json::json!(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.authed_post(
|
||||||
|
localpart,
|
||||||
|
&format!("/api/v1/repos/{owner}/{repo}/issues"),
|
||||||
|
&json,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("create issue failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_pulls(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
state: Option<&str>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<Vec<PullRequest>, String> {
|
||||||
|
let param_str = {
|
||||||
|
let mut encoder = form_urlencoded::Serializer::new(String::new());
|
||||||
|
if let Some(s) = state {
|
||||||
|
encoder.append_pair("state", s);
|
||||||
|
}
|
||||||
|
if let Some(n) = limit {
|
||||||
|
encoder.append_pair("limit", &n.to_string());
|
||||||
|
}
|
||||||
|
let encoded = encoder.finish();
|
||||||
|
if encoded.is_empty() { String::new() } else { format!("?{encoded}") }
|
||||||
|
};
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{repo}/pulls{param_str}");
|
||||||
|
let resp = self.authed_get(localpart, &path).await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("list pulls failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_file(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
path: &str,
|
||||||
|
git_ref: Option<&str>,
|
||||||
|
) -> Result<FileContent, String> {
|
||||||
|
let ref_param = git_ref
|
||||||
|
.map(|r| format!("?ref={r}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let api_path = format!(
|
||||||
|
"/api/v1/repos/{owner}/{repo}/contents/{path}{ref_param}"
|
||||||
|
);
|
||||||
|
let resp = self.authed_get(localpart, &api_path).await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("get file failed: {text}"));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| format!("parse error: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repo_summary_deserialize() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"full_name": "studio/sol",
|
||||||
|
"description": "Virtual librarian",
|
||||||
|
"html_url": "https://src.sunbeam.pt/studio/sol",
|
||||||
|
"open_issues_count": 3,
|
||||||
|
"stars_count": 0,
|
||||||
|
"updated_at": "2026-03-20T10:00:00Z",
|
||||||
|
"private": false,
|
||||||
|
});
|
||||||
|
let repo: RepoSummary = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(repo.full_name, "studio/sol");
|
||||||
|
assert_eq!(repo.open_issues_count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_issue_deserialize() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"number": 42,
|
||||||
|
"title": "Fix thing",
|
||||||
|
"body": "Details here",
|
||||||
|
"state": "open",
|
||||||
|
"html_url": "https://src.sunbeam.pt/studio/sol/issues/42",
|
||||||
|
"user": { "login": "sienna" },
|
||||||
|
"labels": [{ "name": "bug", "color": "ee0701" }],
|
||||||
|
"created_at": "2026-03-20T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-20T10:00:00Z",
|
||||||
|
});
|
||||||
|
let issue: Issue = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(issue.number, 42);
|
||||||
|
assert_eq!(issue.user.login, "sienna");
|
||||||
|
assert_eq!(issue.labels.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pull_request_deserialize() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"number": 7,
|
||||||
|
"title": "Add feature",
|
||||||
|
"body": "",
|
||||||
|
"state": "open",
|
||||||
|
"html_url": "https://src.sunbeam.pt/studio/sol/pulls/7",
|
||||||
|
"user": { "login": "lonni" },
|
||||||
|
"created_at": "2026-03-20T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-20T10:00:00Z",
|
||||||
|
"mergeable": true,
|
||||||
|
});
|
||||||
|
let pr: PullRequest = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(pr.number, 7);
|
||||||
|
assert_eq!(pr.mergeable, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that both 400 and 422 are treated as token name conflicts.
|
||||||
|
#[test]
|
||||||
|
fn test_token_conflict_status_codes() {
|
||||||
|
// These are the status codes Gitea may return for duplicate token names.
|
||||||
|
// Both should trigger the delete+retry path.
|
||||||
|
let conflict_codes: Vec<u16> = vec![400, 422];
|
||||||
|
for code in &conflict_codes {
|
||||||
|
assert!(
|
||||||
|
*code == 400 || *code == 422,
|
||||||
|
"Status {code} should be a conflict code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 201 (success) and 404 (user not found) should NOT be conflict codes
|
||||||
|
assert!(201 != 400 && 201 != 422);
|
||||||
|
assert!(404 != 400 && 404 != 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the token name constant is stable (changing it would orphan existing PATs).
|
||||||
|
#[test]
|
||||||
|
fn test_token_name_constant() {
|
||||||
|
assert_eq!(TOKEN_NAME, "sol-agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify PAT scopes include the minimum required permissions.
|
||||||
|
#[test]
|
||||||
|
fn test_token_scopes() {
|
||||||
|
assert!(TOKEN_SCOPES.contains(&"read:issue"));
|
||||||
|
assert!(TOKEN_SCOPES.contains(&"write:issue"));
|
||||||
|
assert!(TOKEN_SCOPES.contains(&"read:repository"));
|
||||||
|
assert!(TOKEN_SCOPES.contains(&"read:user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_content_deserialize() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"name": "README.md",
|
||||||
|
"path": "README.md",
|
||||||
|
"content": "IyBTb2w=",
|
||||||
|
"type": "file",
|
||||||
|
"size": 1024,
|
||||||
|
});
|
||||||
|
let file: FileContent = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(file.name, "README.md");
|
||||||
|
assert_eq!(file.file_type, "file");
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/sdk/mod.rs
Normal file
3
src/sdk/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod gitea;
|
||||||
|
pub mod tokens;
|
||||||
|
pub mod vault;
|
||||||
180
src/sdk/tokens.rs
Normal file
180
src/sdk/tokens.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::persistence::Store;
|
||||||
|
|
||||||
|
use super::vault::VaultClient;
|
||||||
|
|
||||||
|
/// Check if a token's expiry data indicates it's still valid.
|
||||||
|
/// Returns true if valid (should be used), false if expired.
|
||||||
|
/// PATs (no `expires_at`) are always valid.
|
||||||
|
fn is_token_valid(data: &serde_json::Value) -> bool {
|
||||||
|
let Some(expires_at) = data["expires_at"].as_str() else {
|
||||||
|
return true; // No expiry field = PAT = always valid
|
||||||
|
};
|
||||||
|
if expires_at.is_empty() {
|
||||||
|
return true; // Empty string = no expiry
|
||||||
|
}
|
||||||
|
match chrono::DateTime::parse_from_rfc3339(expires_at) {
|
||||||
|
Ok(exp) => exp >= Utc::now(),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(expires_at, "Failed to parse token expiry: {e}");
|
||||||
|
true // Malformed expiry — treat as valid, let the service reject if stale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token store backed by OpenBao for secrets and SQLite for username mappings.
|
||||||
|
///
|
||||||
|
/// Token storage (PATs, OAuth2 tokens) goes to OpenBao KV at `sol-tokens/{localpart}/{service}`.
|
||||||
|
/// Username mappings (not secrets) stay in SQLite `service_users` table.
|
||||||
|
pub struct TokenStore {
|
||||||
|
store: Arc<Store>,
|
||||||
|
vault: Arc<VaultClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenStore {
|
||||||
|
pub fn new(store: Arc<Store>, vault: Arc<VaultClient>) -> Self {
|
||||||
|
Self { store, vault }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a valid (non-expired) token for a user+service from Vault.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - `Ok(Some(token))` — valid token found
|
||||||
|
/// - `Ok(None)` — no token stored (needs provisioning)
|
||||||
|
/// - `Err(msg)` — Vault error (do NOT provision, surface the error)
|
||||||
|
pub async fn get_valid(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
service: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let path = format!("sol-tokens/{localpart}/{service}");
|
||||||
|
let data = match self.vault.kv_get(&path).await {
|
||||||
|
Ok(Some(d)) => d,
|
||||||
|
Ok(None) => return Ok(None),
|
||||||
|
Err(e) => return Err(format!("failed to read token from Vault: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = match data["token"].as_str() {
|
||||||
|
Some(t) => t.to_string(),
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_token_valid(&data) {
|
||||||
|
debug!(localpart, service, "Token expired, deleting from Vault");
|
||||||
|
if let Err(e) = self.vault.kv_delete(&path).await {
|
||||||
|
warn!("Failed to delete expired token: {e}");
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a token for a user+service in Vault.
|
||||||
|
pub async fn put(
|
||||||
|
&self,
|
||||||
|
localpart: &str,
|
||||||
|
service: &str,
|
||||||
|
token: &str,
|
||||||
|
token_type: &str,
|
||||||
|
refresh_token: Option<&str>,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let path = format!("sol-tokens/{localpart}/{service}");
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"token": token,
|
||||||
|
"token_type": token_type,
|
||||||
|
"refresh_token": refresh_token.unwrap_or(""),
|
||||||
|
"expires_at": expires_at.unwrap_or(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
self.vault.kv_put(&path, data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a token for a user+service from Vault.
|
||||||
|
pub async fn delete(&self, localpart: &str, service: &str) {
|
||||||
|
let path = format!("sol-tokens/{localpart}/{service}");
|
||||||
|
if let Err(e) = self.vault.kv_delete(&path).await {
|
||||||
|
warn!(localpart, service, "Failed to delete token from Vault: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the service-specific username for a Matrix localpart (SQLite).
|
||||||
|
pub fn resolve_username(&self, localpart: &str, service: &str) -> Option<String> {
|
||||||
|
self.store.get_service_user(localpart, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the mapping from Matrix localpart to service-specific username (SQLite).
|
||||||
|
pub fn set_username(&self, localpart: &str, service: &str, service_username: &str) {
|
||||||
|
self.store
|
||||||
|
.upsert_service_user(localpart, service, service_username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Token expiry tests (pure function, no Vault needed) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_expiry_is_valid() {
|
||||||
|
let data = serde_json::json!({"token": "abc"});
|
||||||
|
assert!(is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_expiry_is_valid() {
|
||||||
|
let data = serde_json::json!({"token": "abc", "expires_at": ""});
|
||||||
|
assert!(is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_future_expiry_is_valid() {
|
||||||
|
let data = serde_json::json!({"token": "abc", "expires_at": "2099-01-01T00:00:00+00:00"});
|
||||||
|
assert!(is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_past_expiry_is_invalid() {
|
||||||
|
let data = serde_json::json!({"token": "abc", "expires_at": "2020-01-01T00:00:00+00:00"});
|
||||||
|
assert!(!is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_expiry_treated_as_valid() {
|
||||||
|
let data = serde_json::json!({"token": "abc", "expires_at": "not-a-date"});
|
||||||
|
assert!(is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_null_expiry_is_valid() {
|
||||||
|
let data = serde_json::json!({"token": "abc", "expires_at": null});
|
||||||
|
assert!(is_token_valid(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Username mapping tests (SQLite-backed, no Vault needed) ──
|
||||||
|
|
||||||
|
fn test_store_for_mapping() -> (Arc<Store>, Arc<VaultClient>) {
|
||||||
|
let store = Arc::new(Store::open_memory().unwrap());
|
||||||
|
let vault = Arc::new(VaultClient::new("http://localhost:0", "test", "secret"));
|
||||||
|
(store, vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_username_mapping() {
|
||||||
|
let (store, vault) = test_store_for_mapping();
|
||||||
|
let ts = TokenStore::new(store, vault);
|
||||||
|
assert!(ts.resolve_username("sienna", "gitea").is_none());
|
||||||
|
|
||||||
|
ts.set_username("sienna", "gitea", "siennav");
|
||||||
|
assert_eq!(ts.resolve_username("sienna", "gitea").unwrap(), "siennav");
|
||||||
|
|
||||||
|
ts.set_username("sienna", "gitea", "sienna");
|
||||||
|
assert_eq!(ts.resolve_username("sienna", "gitea").unwrap(), "sienna");
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/sdk/vault.rs
Normal file
252
src/sdk/vault.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use reqwest::Client as HttpClient;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const SA_TOKEN_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
|
||||||
|
/// Client for OpenBao/Vault KV v2 operations, authenticated via Kubernetes auth.
|
||||||
|
pub struct VaultClient {
|
||||||
|
url: String,
|
||||||
|
role: String,
|
||||||
|
kv_mount: String,
|
||||||
|
http: HttpClient,
|
||||||
|
token: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AuthResponse {
|
||||||
|
auth: AuthData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AuthData {
|
||||||
|
client_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct KvReadResponse {
|
||||||
|
data: KvData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct KvData {
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultClient {
|
||||||
|
pub fn new(url: &str, role: &str, kv_mount: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.trim_end_matches('/').to_string(),
|
||||||
|
role: role.to_string(),
|
||||||
|
kv_mount: kv_mount.to_string(),
|
||||||
|
http: HttpClient::new(),
|
||||||
|
token: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate with OpenBao via Kubernetes auth method.
|
||||||
|
/// Reads the service account JWT from the mounted token file.
|
||||||
|
async fn authenticate(&self) -> Result<String, String> {
|
||||||
|
let jwt = tokio::fs::read_to_string(SA_TOKEN_PATH)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to read SA token at {SA_TOKEN_PATH}: {e}"))?;
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(format!("{}/v1/auth/kubernetes/login", self.url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"role": self.role,
|
||||||
|
"jwt": jwt.trim(),
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault auth failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault auth failed (HTTP {status}): {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth: AuthResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse auth response: {e}"))?;
|
||||||
|
|
||||||
|
info!("Authenticated with OpenBao via Kubernetes auth");
|
||||||
|
Ok(auth.auth.client_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a valid Vault token, authenticating if needed.
|
||||||
|
async fn get_token(&self) -> Result<String, String> {
|
||||||
|
let mut cached = self.token.lock().await;
|
||||||
|
if let Some(ref token) = *cached {
|
||||||
|
return Ok(token.clone());
|
||||||
|
}
|
||||||
|
let token = self.authenticate().await?;
|
||||||
|
*cached = Some(token.clone());
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the cached token (on 403, triggers re-auth on next call).
|
||||||
|
async fn clear_token(&self) {
|
||||||
|
let mut cached = self.token.lock().await;
|
||||||
|
*cached = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a KV v2 secret. Returns None if not found.
|
||||||
|
pub async fn kv_get(&self, path: &str) -> Result<Option<serde_json::Value>, String> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/v1/{}/data/{}", self.url, self.kv_mount, path);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault read failed: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 403 {
|
||||||
|
// Token expired — re-auth and retry once
|
||||||
|
warn!("Vault token rejected, re-authenticating");
|
||||||
|
self.clear_token().await;
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault read failed (retry): {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault read failed: {text}"));
|
||||||
|
}
|
||||||
|
let kv: KvReadResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault parse failed: {e}"))?;
|
||||||
|
return Ok(Some(kv.data.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault read failed: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let kv: KvReadResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault parse failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(Some(kv.data.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a KV v2 secret (create or update).
|
||||||
|
pub async fn kv_put(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!("{}/v1/{}/data/{}", self.url, self.kv_mount, path);
|
||||||
|
|
||||||
|
let body = serde_json::json!({ "data": data });
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault write failed: {e}"))?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 403 {
|
||||||
|
warn!("Vault token rejected on write, re-authenticating");
|
||||||
|
self.clear_token().await;
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault write failed (retry): {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault write failed: {text}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault write failed: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(path, "Wrote secret to Vault");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a KV v2 secret.
|
||||||
|
pub async fn kv_delete(&self, path: &str) -> Result<(), String> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/metadata/{}",
|
||||||
|
self.url, self.kv_mount, path
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.delete(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault delete failed: {e}"))?;
|
||||||
|
|
||||||
|
// 404 = already gone, that's fine
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 403 {
|
||||||
|
self.clear_token().await;
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.delete(&url)
|
||||||
|
.header("X-Vault-Token", &token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("vault delete failed (retry): {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault delete failed: {text}"));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("vault delete failed: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user