feat: scaffold Secrets Service Rust project (#22) #107

Merged
shahondin1624 merged 1 commits from feature/issue-22-secrets-scaffold into main 2026-03-09 08:02:54 +01:00
9 changed files with 369 additions and 0 deletions

19
Cargo.lock generated
View File

@@ -764,6 +764,25 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secrets-service"
version = "0.1.0"
dependencies = [
"anyhow",
"llm-multiverse-proto",
"prost",
"prost-types",
"serde",
"tempfile",
"thiserror",
"tokio",
"tokio-stream",
"toml",
"tonic",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "semver"
version = "1.0.27"

View File

@@ -3,4 +3,5 @@ resolver = "2"
members = [
"gen/rust",
"services/audit",
"services/secrets",
]

View File

@@ -19,6 +19,7 @@
| #19 | Implement append-only file log backend | Phase 2 | `COMPLETED` | Rust | [issue-019.md](issue-019.md) |
| #20 | Implement Append gRPC endpoint | Phase 2 | `COMPLETED` | Rust | [issue-020.md](issue-020.md) |
| #21 | Integration tests for Audit Service | Phase 2 | `COMPLETED` | Rust | [issue-021.md](issue-021.md) |
| #22 | Scaffold Secrets Service Rust project | Phase 3 | `COMPLETED` | Rust | [issue-022.md](issue-022.md) |
## Status Legend
@@ -39,6 +40,7 @@
### Secrets Service
- [issue-010.md](issue-010.md) — secrets.proto (SecretsService, GetSecret)
- [issue-022.md](issue-022.md) — Scaffold Secrets Service Rust project
### Memory Service
- [issue-011.md](issue-011.md) — memory.proto (MemoryService, MemoryEntry)

View File

@@ -0,0 +1,41 @@
# Implementation Plan — Issue #22: Scaffold Secrets Service Rust project
## Metadata
| Field | Value |
|---|---|
| Issue | [#22](https://git.shahondin1624.de/llm-multiverse/llm-multiverse/issues/22) |
| Title | Scaffold Secrets Service Rust project |
| Milestone | Phase 3: Secrets Service |
| Labels | `type:infrastructure`, `priority:high`, `lang:rust`, `service:secrets` |
| Status | `COMPLETED` |
| Language | Rust |
| Related Plans | [issue-018.md](issue-018.md) |
| Blocked by | #16 (completed) |
## Acceptance Criteria
- [x] Cargo workspace member created (`services/secrets/`)
- [x] Dependency on proto-gen crate
- [x] Tonic gRPC server boilerplate compiles
- [x] Configuration loading (address, port, backend selection)
- [x] Stub GetSecret endpoint with input validation
## Architecture Analysis
Follows the same pattern as audit service scaffold (#18). Config supports `BackendType` enum (libsecret, keyring) for future backend implementations (#23, #24). Stub service validates inputs and returns Unimplemented until backends are wired in.
## Files to Create/Modify
| File | Action | Purpose |
|---|---|---|
| `Cargo.toml` | Modify | Add services/secrets to workspace members |
| `services/secrets/Cargo.toml` | Create | Secrets service crate |
| `services/secrets/src/lib.rs` | Create | Module re-exports |
| `services/secrets/src/main.rs` | Create | Server entry point |
| `services/secrets/src/config.rs` | Create | Configuration with backend selection |
| `services/secrets/src/service.rs` | Create | SecretsService stub with validation |
## Deviation Log
_(No deviations)_

View File

@@ -0,0 +1,24 @@
[package]
name = "secrets-service"
version = "0.1.0"
edition = "2021"
description = "Credential retrieval service for llm-multiverse"
publish = false
[dependencies]
llm-multiverse-proto = { path = "../../gen/rust" }
tonic = "0.13"
prost = "0.13"
prost-types = "0.13"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
anyhow = "1"
thiserror = "2"
[dev-dependencies]
tempfile = "3"
tonic = { version = "0.13", features = ["transport"] }
tokio-stream = "0.1"

View File

@@ -0,0 +1,118 @@
use serde::Deserialize;
/// Backend type for secret retrieval.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BackendType {
/// Linux Secret Service API via D-Bus (libsecret).
Libsecret,
/// Linux kernel keyring.
Keyring,
}
fn default_backend() -> BackendType {
BackendType::Libsecret
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_backend")]
pub backend: BackendType,
}
fn default_host() -> String {
"[::1]".to_string()
}
fn default_port() -> u16 {
50053
}
impl Default for Config {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
backend: default_backend(),
}
}
}
impl Config {
/// Load config from file, falling back to defaults.
pub fn load(path: Option<&str>) -> anyhow::Result<Self> {
match path {
Some(p) => {
let contents = std::fs::read_to_string(p)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
None => Ok(Self::default()),
}
}
pub fn listen_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.host, "[::1]");
assert_eq!(config.port, 50053);
assert_eq!(config.backend, BackendType::Libsecret);
}
#[test]
fn test_listen_addr() {
let config = Config::default();
assert_eq!(config.listen_addr(), "[::1]:50053");
}
#[test]
fn test_load_from_toml() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("secrets.toml");
std::fs::write(
&config_path,
r#"
host = "0.0.0.0"
port = 9999
backend = "keyring"
"#,
)
.unwrap();
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 9999);
assert_eq!(config.backend, BackendType::Keyring);
}
#[test]
fn test_load_no_file_uses_defaults() {
let config = Config::load(None).unwrap();
assert_eq!(config.port, 50053);
assert_eq!(config.backend, BackendType::Libsecret);
}
#[test]
fn test_backend_deserialization() {
let toml_str = r#"backend = "libsecret""#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.backend, BackendType::Libsecret);
let toml_str = r#"backend = "keyring""#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.backend, BackendType::Keyring);
}
}

View File

@@ -0,0 +1,2 @@
pub mod config;
pub mod service;

View File

@@ -0,0 +1,44 @@
use llm_multiverse_proto::llm_multiverse::v1::secrets_service_server::SecretsServiceServer;
use secrets_service::config::Config;
use secrets_service::service::SecretsServiceImpl;
use tonic::transport::Server;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env()
.add_directive("secrets_service=info".parse()?),
)
.init();
let config_path = std::env::var("SECRETS_CONFIG").ok();
let config = Config::load(config_path.as_deref())?;
tracing::info!(
addr = %config.listen_addr(),
backend = ?config.backend,
"Starting Secrets Service"
);
let addr = config.listen_addr().parse()?;
let secrets_service = SecretsServiceImpl::new();
tracing::info!(%addr, "Secrets Service listening");
Server::builder()
.add_service(SecretsServiceServer::new(secrets_service))
.serve_with_shutdown(addr, shutdown_signal())
.await?;
tracing::info!("Secrets Service shut down gracefully");
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for ctrl+c");
tracing::info!("Shutdown signal received");
}

View File

@@ -0,0 +1,118 @@
use llm_multiverse_proto::llm_multiverse::v1::{
secrets_service_server::SecretsService, GetSecretRequest, GetSecretResponse,
};
use tonic::{Request, Response, Status};
/// Secrets service implementation.
/// Currently a stub — backend implementations (#23, #24) will be wired in later.
pub struct SecretsServiceImpl;
impl Default for SecretsServiceImpl {
fn default() -> Self {
Self
}
}
impl SecretsServiceImpl {
pub fn new() -> Self {
Self
}
}
#[tonic::async_trait]
impl SecretsService for SecretsServiceImpl {
async fn get_secret(
&self,
request: Request<GetSecretRequest>,
) -> Result<Response<GetSecretResponse>, Status> {
let req = request.into_inner();
// Validate session context.
let ctx = req
.context
.ok_or_else(|| Status::invalid_argument("session context is required"))?;
if ctx.session_id.is_empty() {
return Err(Status::invalid_argument("context.session_id is required"));
}
// Validate secret name.
if req.secret_name.is_empty() {
return Err(Status::invalid_argument("secret_name is required"));
}
tracing::debug!(
session_id = %ctx.session_id,
secret_name = %req.secret_name,
"GetSecret requested"
);
// Backend not yet implemented — return unimplemented.
Err(Status::unimplemented(
"secret backend not yet implemented (see issues #23, #24)",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use llm_multiverse_proto::llm_multiverse::v1::SessionContext;
fn make_request(
context: Option<SessionContext>,
secret_name: &str,
) -> Request<GetSecretRequest> {
Request::new(GetSecretRequest {
context,
secret_name: secret_name.into(),
})
}
#[tokio::test]
async fn test_rejects_missing_context() {
let svc = SecretsServiceImpl::new();
let result = svc.get_secret(make_request(None, "api_key")).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
}
#[tokio::test]
async fn test_rejects_empty_session_id() {
let svc = SecretsServiceImpl::new();
let ctx = SessionContext {
session_id: "".into(),
..Default::default()
};
let result = svc.get_secret(make_request(Some(ctx), "api_key")).await;
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status.message().contains("context.session_id"));
}
#[tokio::test]
async fn test_rejects_empty_secret_name() {
let svc = SecretsServiceImpl::new();
let ctx = SessionContext {
session_id: "sess-1".into(),
..Default::default()
};
let result = svc.get_secret(make_request(Some(ctx), "")).await;
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status.message().contains("secret_name"));
}
#[tokio::test]
async fn test_valid_request_returns_unimplemented() {
let svc = SecretsServiceImpl::new();
let ctx = SessionContext {
session_id: "sess-1".into(),
..Default::default()
};
let result = svc.get_secret(make_request(Some(ctx), "api_key")).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), tonic::Code::Unimplemented);
}
}