feat(grpc): CLI 'system status' round-trips to the daemon over gRPC

Move the gRPC address resolver and add an authenticated client helper
(connect_system + BearerTokenInterceptor) into the shared nuncio-proto crate so
client shells never depend on the nunciod binary. Wire nuncio-cli's system
status to query the live daemon over gRPC (token from an injectable OS keyring),
printing real status or an honest error when unreachable. Refactor nunciod to
reuse the shared addr items. Reconcile tests to assert the real gRPC round-trip
instead of local ephemeral state.

Refs #150
This commit is contained in:
James Maes committed 2026-07-26 22:06:10 -05:00
1 parent 417afa0448
commit d8b962d499
10 files changed
+569 -46

No files matched your search

Generated
+6
View File
@@ -2466,11 +2466,13 @@ version = "0.1.0"
dependencies = [
"chrono",
"clap",
"hex",
"nuncio-cal",
"nuncio-contacts",
"nuncio-core",
"nuncio-filter",
"nuncio-mail",
"nuncio-proto",
"nuncio-store",
"rpassword",
"serde",
@@ -2478,6 +2480,8 @@ dependencies = [
"sqlx",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tonic",
"tracing",
]
@@ -2579,6 +2583,8 @@ version = "0.1.0"
dependencies = [
"prost",
"protoc-bin-vendored",
"thiserror 1.0.69",
"tokio",
"tonic",
"tonic-prost",
"tonic-prost-build",
+6
View File
@@ -16,6 +16,7 @@ nuncio-cal = { path = "../nuncio-cal" }
nuncio-store = { path = "../nuncio-store" }
nuncio-filter = { path = "../nuncio-filter" }
nuncio-contacts = { path = "../nuncio-contacts" }
nuncio-proto = { path = "../nuncio-proto" }
tokio.workspace = true
sqlx.workspace = true
@@ -26,5 +27,10 @@ thiserror.workspace = true
tracing.workspace = true
rpassword.workspace = true
chrono.workspace = true
hex.workspace = true
[dev-dependencies]
tonic.workspace = true
tokio-stream = { workspace = true, features = ["net"] }
+202 -22
View File
@@ -1,8 +1,10 @@
//! Headless engine runner executing CLI commands against core services.
use nuncio_core::{CoreCommand, EventBus};
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nuncio_store::{DatabaseEngine, DatabaseError};
use serde_json::json;
use std::sync::Arc;
use thiserror::Error;
use crate::args::{
@@ -24,19 +26,54 @@ pub enum RunnerError {
}
/// Headless core runner executing CLI commands non-interactively.
///
/// Most commands operate against an ephemeral local engine (database +
/// event bus) for now. `system status` (see [`Self::handle_system_status`])
/// is the exception: it is a thin gRPC client of the real `nunciod` daemon's
/// `nuncio.v1.System` API (backlog story 1.A.3 / GH-150), authenticated by a
/// bearer token read from an injected [`SecretManager`] — production code
/// uses [`SecretManager::production`] (the real OS keyring), while tests
/// inject [`SecretManager::mock`] so no test ever touches the real vault.
pub struct HeadlessRunner {
event_bus: EventBus,
db: DatabaseEngine,
secrets: Arc<SecretManager>,
grpc_addr: String,
}
impl HeadlessRunner {
/// Initialize a new `HeadlessRunner` with an ephemeral database.
/// Initialize a new `HeadlessRunner` with an ephemeral database, the
/// real OS keyring vault ([`SecretManager::production`]), and the gRPC
/// daemon address resolved from [`nuncio_proto::grpc_addr_from_env`].
pub async fn ephemeral() -> Result<Self, RunnerError> {
Self::ephemeral_with(
Arc::new(SecretManager::production()),
nuncio_proto::grpc_addr_from_env(),
)
.await
}
/// Initialize a new `HeadlessRunner` with an ephemeral database and an
/// explicit secret vault + gRPC daemon address.
///
/// This is the constructor tests MUST use whenever they exercise
/// `system status`: pass a [`SecretManager::mock`]-backed instance
/// (never the real OS keyring) and the address of a test-local gRPC
/// server.
pub async fn ephemeral_with(
secrets: Arc<SecretManager>,
grpc_addr: String,
) -> Result<Self, RunnerError> {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.map_err(|e| RunnerError::InitFailed(e.to_string()))?;
let event_bus = EventBus::new();
Ok(Self { event_bus, db })
Ok(Self {
event_bus,
db,
secrets,
grpc_addr,
})
}
/// Access the underlying `EventBus`.
@@ -787,27 +824,63 @@ impl HeadlessRunner {
}
}
/// `system status`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.System` API (backlog story 1.A.3 / GH-150).
///
/// Resolves the bearer token from the injected `SecretManager`, dials
/// the configured gRPC daemon address via
/// `nuncio_proto::client::connect_system`, and calls `GetStatus`. If the
/// daemon is unreachable or rejects the call, this returns a clear,
/// honest error — it never fabricates a status. This deliberately does
/// NOT auto-spawn the daemon.
async fn handle_system_status(&self, json_mode: bool) -> String {
let state = self.event_bus.current_state();
let healthy = self.db.check_integrity().await.unwrap_or(false);
if json_mode {
format_json(&json!({
"accounts_loaded": state.accounts_loaded,
"unread_count": state.unread_count,
"engine_status": format!("{:?}", state.status),
"database_health": if healthy { "healthy" } else { "repaired" }
}))
} else {
let mut msg = format!(
"Nuncio Configuration: {} accounts loaded, {} unread messages",
state.accounts_loaded, state.unread_count
);
if !healthy {
msg.push_str("\n[NOTICE] Database integrity issue was automatically repaired. Resynchronizing inbox...");
match self.query_daemon_status().await {
Ok((engine_status, version)) => {
if json_mode {
format_json(&json!({
"engine_status": engine_status,
"version": version,
}))
} else {
format!(
"Nuncio daemon status: {engine_status} (nunciod v{version}, {})",
self.grpc_addr
)
}
}
Err(e) => {
if json_mode {
format_json_error(&e)
} else {
format!("Error: {e}")
}
}
msg
}
}
/// Reads the gRPC bearer token from the injected vault, connects to the
/// daemon over gRPC, and calls `GetStatus`. Returns `(engine_status,
/// version)` on success, or a human-readable error string describing
/// exactly what failed (vault, connection, or the RPC itself).
async fn query_daemon_status(&self) -> Result<(String, String), String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let mut client = nuncio_proto::client::connect_system(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))?;
let response = client
.get_status(nuncio_proto::v1::GetStatusRequest {})
.await
.map_err(|e| format!("nunciod daemon rejected status request: {e}"))?
.into_inner();
Ok((response.engine_status, response.version))
}
}
#[cfg(test)]
@@ -954,8 +1027,38 @@ mod tests {
.await;
assert!(cal_sync.contains(r#""status":"calendar_sync_started""#));
// System Noun Commands
let sys_status = runner
// System Noun Commands are exercised separately below via
// `ephemeral_with` + `SecretManager::mock()`: `system status` is a
// real gRPC client of the `nunciod` daemon (backlog story 1.A.3 /
// GH-150), so it must never run against the production
// `SecretManager` this `ephemeral()`-constructed runner holds (that
// would touch the real OS keyring during a test run).
}
/// Binds an ephemeral loopback TCP listener, reads back its OS-assigned
/// address, then immediately drops the listener so the port is free
/// again. Nothing is listening on the returned address, so connecting
/// to it deterministically fails with "connection refused" — used to
/// prove `system status` reports an honest error instead of fabricating
/// one when no daemon is reachable, without depending on any specific
/// hardcoded port that might collide with a real service.
async fn reserve_unreachable_addr() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener);
addr.to_string()
}
#[tokio::test]
async fn system_status_reports_honest_error_when_daemon_unreachable() {
let addr = reserve_unreachable_addr().await;
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
.await
.expect("ephemeral runner initializes");
let json_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
@@ -963,7 +1066,84 @@ mod tests {
true,
)
.await;
assert!(sys_status.contains(r#""unread_count":0"#));
assert!(json_out.contains(r#""status":"error""#));
assert!(json_out.contains("unreachable"));
let text_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
false,
)
.await;
assert!(text_out.starts_with("Error: "));
assert!(text_out.contains("unreachable"));
}
#[tokio::test]
async fn system_status_reports_live_daemon_status_over_grpc_when_reachable() {
use nuncio_proto::v1::system_server::{System as SystemService, SystemServer};
use nuncio_proto::v1::{GetStatusRequest, GetStatusResponse};
/// Minimal test-only stub of the `nuncio.v1.System` service: no
/// auth interceptor, just a fixed status. Bearer-token acceptance
/// itself is proven by `nuncio-proto`'s own client tests and
/// `nunciod`'s server + E2E tests; this test exists purely to
/// prove the CLI's connect + call + JSON-format happy path.
struct StubSystem;
#[tonic::async_trait]
impl SystemService for StubSystem {
async fn get_status(
&self,
_request: tonic::Request<GetStatusRequest>,
) -> Result<tonic::Response<GetStatusResponse>, tonic::Status> {
Ok(tonic::Response::new(GetStatusResponse {
engine_status: "Ready".to_string(),
version: "9.9.9".to_string(),
}))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(SystemServer::new(StubSystem))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
let out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await;
assert!(out.contains(r#""status":"ok""#));
assert!(out.contains(r#""engine_status":"Ready""#));
assert!(out.contains(r#""version":"9.9.9""#));
let text_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
false,
)
.await;
assert!(text_out.contains("Ready"));
assert!(text_out.contains("9.9.9"));
}
#[test]
+39 -11
View File
@@ -6,23 +6,19 @@ use nuncio_cli::{
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, HeadlessRunner, MailSubcommand,
SystemSubcommand,
};
use nuncio_store::vault::SecretManager;
use serde_json::Value;
use std::sync::Arc;
#[tokio::test]
async fn system_test_cli_noun_verb_execution_matrix() {
let runner = HeadlessRunner::ephemeral().await.expect("runner init");
// 1. System status
let out: String = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 1. System status is a real gRPC client of the `nunciod` daemon
// (backlog story 1.A.3 / GH-150), so it is exercised separately below
// via `ephemeral_with` + `SecretManager::mock()` rather than through
// this `ephemeral()`-constructed runner (which holds the production
// `SecretManager` and must never touch the real OS keyring in a test).
// 1b. Banner output
let out: String = runner.execute_command(&Commands::Banner, true).await;
@@ -117,3 +113,35 @@ async fn system_test_cli_noun_verb_execution_matrix() {
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
}
/// `system status` is a real gRPC client of the `nunciod` daemon (backlog
/// story 1.A.3 / GH-150). With no daemon reachable at an address nothing is
/// listening on, it must report a clear, honest error rather than
/// fabricating a status.
#[tokio::test]
async fn system_status_reports_honest_error_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener); // free the port; nothing is listening on it now
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("runner init");
let out: String = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "error");
assert!(json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
}
+4
View File
@@ -13,7 +13,11 @@ workspace = true
tonic.workspace = true
tonic-prost.workspace = true
prost.workspace = true
thiserror.workspace = true
[build-dependencies]
tonic-prost-build.workspace = true
protoc-bin-vendored.workspace = true
[dev-dependencies]
tokio.workspace = true
+43
View File
@@ -0,0 +1,43 @@
//! Shared loopback address configuration for the `nuncio.v1` gRPC API.
//!
//! This is the single source of truth for the daemon's gRPC bind address
//! defaults, consumed by both the server (`nunciod`) and any thin
//! presentation-shell client (e.g. `nuncio-cli`) that needs to dial the same
//! endpoint without depending on the `nunciod` binary crate (backlog story
//! 1.A.3 / GH-150).
/// Default loopback bind address for the `nuncio.v1` gRPC server, used when
/// the [`GRPC_ADDR_ENV_VAR`] environment variable is unset. Deliberately
/// distinct from the legacy JSON-RPC IPC server's default (`127.0.0.1:9422`).
pub const DEFAULT_GRPC_ADDR: &str = "127.0.0.1:9420";
/// Environment variable overriding the gRPC bind/connect address.
pub const GRPC_ADDR_ENV_VAR: &str = "NUNCIO_GRPC_ADDR";
/// Resolves the gRPC address from [`GRPC_ADDR_ENV_VAR`], falling back to
/// [`DEFAULT_GRPC_ADDR`] when unset. Used both by the server to pick a bind
/// address and by clients to pick a connect address, so they agree by
/// default without any extra configuration.
pub fn grpc_addr_from_env() -> String {
std::env::var(GRPC_ADDR_ENV_VAR).unwrap_or_else(|_| DEFAULT_GRPC_ADDR.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grpc_addr_from_env_defaults_when_unset() {
// Serialize access to the shared process environment variable so
// this test cannot interleave with any other test touching it.
static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var(GRPC_ADDR_ENV_VAR);
assert_eq!(grpc_addr_from_env(), DEFAULT_GRPC_ADDR);
std::env::set_var(GRPC_ADDR_ENV_VAR, "127.0.0.1:12345");
assert_eq!(grpc_addr_from_env(), "127.0.0.1:12345");
std::env::remove_var(GRPC_ADDR_ENV_VAR);
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Shared client-side connection helpers for the `nuncio.v1` gRPC API.
//!
//! Any thin presentation-shell client (`nuncio-cli`, and future `nuncio-tui`
//! / `nuncio-gui` / `nuncio-mcp` clients) that needs to talk to the running
//! `nunciod` daemon over gRPC should go through [`connect_system`] rather
//! than hand-rolling channel setup and bearer-token metadata injection. This
//! keeps those crates free of any dependency on the `nunciod` binary crate
//! itself (backlog story 1.A.3 / GH-150) while still sharing a single
//! implementation of the client-side authentication handshake with the
//! server-side interceptor in `nunciod::grpc`.
use crate::v1::system_client::SystemClient;
use thiserror::Error;
use tonic::metadata::errors::InvalidMetadataValue;
use tonic::metadata::{Ascii, MetadataValue};
use tonic::service::interceptor::InterceptedService;
use tonic::transport::{Channel, Endpoint};
use tonic::{Request, Status};
/// Errors that can occur while establishing an authenticated client
/// connection to the `nuncio.v1.System` gRPC service.
#[derive(Debug, Error)]
pub enum ConnectError {
/// The provided bearer token could not be encoded as gRPC metadata
/// (e.g. contained non-ASCII bytes).
#[error("bearer token is not valid gRPC metadata: {0}")]
InvalidToken(#[from] InvalidMetadataValue),
/// `addr` could not be parsed into a valid gRPC endpoint.
#[error("invalid gRPC endpoint 'http://{addr}': {source}")]
InvalidEndpoint {
/// The address that failed to parse.
addr: String,
/// Underlying tonic transport error.
#[source]
source: tonic::transport::Error,
},
/// The transport connection to `addr` could not be established.
#[error("failed to connect to nunciod gRPC endpoint 'http://{addr}': {source}")]
Transport {
/// The address that could not be reached.
addr: String,
/// Underlying tonic transport error.
#[source]
source: tonic::transport::Error,
},
}
/// Client-side interceptor that injects `authorization: Bearer <token>` into
/// the metadata of every outgoing request, mirroring the header shape
/// expected by the server-side `BearerAuthInterceptor` in `nunciod::grpc`.
///
/// The header value is parsed once at construction time (via [`Self::new`])
/// rather than on every call, so a malformed token fails fast when
/// connecting instead of surfacing as an opaque per-request error.
#[derive(Debug, Clone)]
pub struct BearerTokenInterceptor {
header_value: MetadataValue<Ascii>,
}
impl BearerTokenInterceptor {
/// Builds the interceptor from a raw bearer token (e.g. the hex-encoded
/// value produced by `SecretManager::get_or_create_key_bytes` +
/// `hex::encode`).
pub fn new(token: &str) -> Result<Self, ConnectError> {
let header_value = format!("Bearer {token}").parse::<MetadataValue<Ascii>>()?;
Ok(Self { header_value })
}
}
impl tonic::service::Interceptor for BearerTokenInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
request
.metadata_mut()
.insert("authorization", self.header_value.clone());
Ok(request)
}
}
/// The authenticated `nuncio.v1.System` client type returned by
/// [`connect_system`].
pub type AuthenticatedSystemClient =
SystemClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials the `nuncio.v1.System` gRPC endpoint at `addr` (a `host:port` pair,
/// e.g. `127.0.0.1:9420`) and returns a client that injects
/// `authorization: Bearer <token>` metadata on every call.
///
/// `addr` is expected to be a loopback address; this helper does not
/// enforce that itself, matching the daemon's own loopback-only bind
/// contract (see `nunciod::grpc::serve`).
pub async fn connect_system(
addr: &str,
token: &str,
) -> Result<AuthenticatedSystemClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let uri = format!("http://{addr}");
let endpoint = Endpoint::from_shared(uri).map_err(|source| ConnectError::InvalidEndpoint {
addr: addr.to_string(),
source,
})?;
let channel = endpoint
.connect()
.await
.map_err(|source| ConnectError::Transport {
addr: addr.to_string(),
source,
})?;
Ok(SystemClient::with_interceptor(channel, interceptor))
}
#[cfg(test)]
mod tests {
use super::*;
use tonic::service::Interceptor as _;
#[test]
fn bearer_token_interceptor_rejects_token_with_invalid_header_bytes() {
// A raw newline is not a legal HTTP header value byte (unlike
// accented/opaque high bytes, which `HeaderValue` actually permits
// as legacy obs-text), so this deterministically exercises the
// `InvalidToken` failure path.
let err = BearerTokenInterceptor::new("tok\ntoken")
.expect_err("token containing a control byte must fail to build a header value");
assert!(matches!(err, ConnectError::InvalidToken(_)));
assert!(err.to_string().contains("not valid gRPC metadata"));
}
#[test]
fn bearer_token_interceptor_injects_authorization_header() {
let mut interceptor =
BearerTokenInterceptor::new("abc123").expect("valid ascii token builds");
let request = Request::new(());
let request = interceptor
.call(request)
.expect("interceptor injects header without error");
let header = request
.metadata()
.get("authorization")
.expect("authorization header present")
.to_str()
.expect("header is valid ascii");
assert_eq!(header, "Bearer abc123");
}
#[tokio::test]
async fn connect_system_fails_closed_on_invalid_token() {
let err = connect_system("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_system_reports_transport_error_when_daemon_unreachable() {
// Port 0 alone is not connectable; using an explicit closed port on
// loopback deterministically triggers connection refusal without a
// live daemon, proving `connect_system` surfaces an honest
// `ConnectError::Transport` rather than hanging or fabricating
// success.
let err = connect_system("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_system_reports_invalid_endpoint_for_a_malformed_address() {
// An embedded space is not a legal URI authority character, so
// `Endpoint::from_shared` rejects `http://<addr>` before any
// connection attempt is made, exercising `InvalidEndpoint`
// separately from the `Transport` (connection-refused) failure
// mode above.
let err = connect_system("127.0.0.1: 9420", "abc123")
.await
.expect_err("malformed address must fail to build a valid endpoint");
assert!(matches!(err, ConnectError::InvalidEndpoint { .. }));
assert!(err.to_string().contains("127.0.0.1: 9420"));
}
}
+14
View File
@@ -11,6 +11,20 @@
//! Consumers (e.g. `nunciod`, `nuncio-cli`, and future client repos) should
//! depend on this crate and use the generated client/server types under
//! [`v1`] rather than re-generating protobuf code themselves.
//!
//! This crate is also the single source of truth for the gRPC loopback
//! address defaults ([`addr`]) and the shared authenticated client
//! connection helper ([`client::connect_system`]), so that thin
//! presentation-shell clients (e.g. `nuncio-cli`) can talk to the `nunciod`
//! daemon's `nuncio.v1.System` API without depending on the `nunciod`
//! binary crate itself (backlog story 1.A.3 / GH-150).
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod addr;
pub mod client;
pub use addr::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR};
/// Generated code for the `nuncio.v1` protobuf package.
///
+7 -13
View File
@@ -18,13 +18,13 @@ use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};
/// Default loopback bind address for the `nuncio.v1` gRPC server, used when
/// the [`GRPC_ADDR_ENV_VAR`] environment variable is unset. Deliberately
/// distinct from the legacy JSON-RPC IPC server's default (`127.0.0.1:9422`).
pub const DEFAULT_GRPC_ADDR: &str = "127.0.0.1:9420";
/// Environment variable overriding the gRPC bind address.
pub const GRPC_ADDR_ENV_VAR: &str = "NUNCIO_GRPC_ADDR";
// The gRPC loopback address defaults/resolver live in `nuncio-proto` (see
// `nuncio_proto::addr`) so that thin presentation-shell clients (e.g.
// `nuncio-cli`) agree on the same default address without depending on this
// `nunciod` binary crate. Re-exported here so existing callers of
// `nunciod::grpc::{DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR, grpc_addr_from_env}`
// keep working unchanged (backlog story 1.A.3 / GH-150).
pub use nuncio_proto::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR};
/// Errors that can occur while binding or running the `nuncio.v1` gRPC server.
#[derive(Debug, Error)]
@@ -43,12 +43,6 @@ pub enum GrpcServeError {
Transport(#[from] tonic::transport::Error),
}
/// Resolves the gRPC bind address from [`GRPC_ADDR_ENV_VAR`], falling back to
/// [`DEFAULT_GRPC_ADDR`] when unset.
pub fn grpc_addr_from_env() -> String {
std::env::var(GRPC_ADDR_ENV_VAR).unwrap_or_else(|_| DEFAULT_GRPC_ADDR.to_string())
}
/// `nuncio.v1.System` gRPC service implementation backed by the daemon's live
/// [`EventBus`] state.
struct SystemGrpcService {
+66
View File
@@ -14,7 +14,9 @@
use nuncio_cli::{Commands, HeadlessRunner, MailSubcommand, SystemSubcommand};
use nuncio_core::ipc::{IpcClient, IpcDaemonServer};
use nuncio_core::{CoreCommand, EventBus};
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use std::sync::Arc;
use tokio::net::TcpListener;
/// Boots a real `nunciod` `IpcDaemonServer` and drives it concurrently from
/// three independent `IpcClient` socket connections (standing in for any
@@ -94,6 +96,65 @@ async fn e2e_multi_shell_daemon_concurrency_test() {
.await;
assert!(cli_sync.contains(r#""status":"sync_started""#));
// `system status` no longer reflects this `HeadlessRunner`'s own local
// ephemeral state: it is a real gRPC client of the `nunciod` daemon
// (backlog story 1.A.3 / GH-150). That round trip is exercised in full,
// against a real `nunciod` gRPC server, by
// `cli_system_status_round_trips_over_grpc_to_live_daemon` below.
}
/// Reference-client proof for backlog story 1.A.3 (GH-150): boots a real
/// `nunciod` `nuncio.v1.System` gRPC server (via
/// [`nunciod::grpc::serve_on_listener`]) on an ephemeral loopback port,
/// authenticated by a bearer token minted from a [`SecretManager::mock`]
/// (never the real OS keyring), then drives the real in-workspace
/// `nuncio-cli` `HeadlessRunner`'s `system status` gRPC client path against
/// it — sharing the *same* mock `SecretManager` instance so the CLI reads
/// back the identical previously-minted token rather than needing any
/// out-of-band secret transfer. Asserts the CLI reports the daemon's real,
/// live engine state (not a fabricated or default one) over the wire.
#[tokio::test]
async fn cli_system_status_round_trips_over_grpc_to_live_daemon() {
// 1. Bring the daemon's live engine state to a known, non-default
// status so this test cannot pass by accident against a hardcoded
// fallback value anywhere in the round trip.
let event_bus = Arc::new(EventBus::new());
event_bus.process_command(CoreCommand::SyncAll);
assert_eq!(
event_bus.current_state().status,
nuncio_core::EngineStatus::Syncing
);
// 2. Mint the gRPC bearer token from a mock vault (never the real OS
// keyring) and share the *same* `SecretManager` instance between the
// server-minting step and the CLI client below.
let secrets = Arc::new(SecretManager::mock());
let token_bytes = secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints gRPC bearer token");
let token = hex::encode(&token_bytes);
// 3. Boot the real `nunciod` gRPC server on an ephemeral loopback port.
// Binding synchronously (before spawning) means the OS is already
// accepting connections on `addr` once this call returns, so the CLI
// client below can dial it immediately with no fixed sleep.
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let server_event_bus = event_bus.clone();
let _server_handle = tokio::spawn(async move {
let _ = nunciod::grpc::serve_on_listener(listener, server_event_bus, token).await;
});
// 4. Drive the real `nuncio-cli` `HeadlessRunner`'s gRPC `system status`
// client path against the live daemon, reading the token back from the
// shared mock `SecretManager` (proving the CLI's injected-vault design
// works end-to-end, not just against a hand-rolled token string).
let cli_runner = HeadlessRunner::ephemeral_with(secrets, addr.to_string())
.await
.expect("cli headless runner initializes");
let cli_status = cli_runner
.execute_command(
&Commands::System {
@@ -102,5 +163,10 @@ async fn e2e_multi_shell_daemon_concurrency_test() {
true,
)
.await;
assert!(
cli_status.contains(r#""status":"ok""#),
"expected a successful status envelope, got: {cli_status}"
);
assert!(cli_status.contains(r#""engine_status":"Syncing""#));
assert!(cli_status.contains(env!("CARGO_PKG_VERSION")));
}