feat: scaffold Secrets Service Rust project (#22) #107
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -3,4 +3,5 @@ resolver = "2"
|
||||
members = [
|
||||
"gen/rust",
|
||||
"services/audit",
|
||||
"services/secrets",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
implementation-plans/issue-022.md
Normal file
41
implementation-plans/issue-022.md
Normal 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)_
|
||||
24
services/secrets/Cargo.toml
Normal file
24
services/secrets/Cargo.toml
Normal 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"
|
||||
118
services/secrets/src/config.rs
Normal file
118
services/secrets/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
services/secrets/src/lib.rs
Normal file
2
services/secrets/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod config;
|
||||
pub mod service;
|
||||
44
services/secrets/src/main.rs
Normal file
44
services/secrets/src/main.rs
Normal 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");
|
||||
}
|
||||
118
services/secrets/src/service.rs
Normal file
118
services/secrets/src/service.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user