Add RabbitMQ validation, test scaffolding, and config refactor

- Extract ipl() from main() with env-aware error handling (fatal in prod, warn in dev)
- Add amqp::validate() — TCP reachability check for RabbitMQ at IPL
- Refactor config::load() into load() + load_from() for testability
- Add lib.rs to expose public API to integration test harness
- Add test fixture scaffolding: tests/fixtures/beds_test.toml, tests/common/mod.rs
- Add unit tests for amqp::validate() error paths (closed port, bad address)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 19:32:24 -07:00
parent 8a89fe3403
commit c1d1ff14a5
6 changed files with 343 additions and 11 deletions

86
src/amqp.rs Normal file
View File

@@ -0,0 +1,86 @@
//! # amqp.rs — RabbitMQ Transport Layer
//!
//! Manages all AMQP interactions for the BEDS node. At IPL, validates that
//! the RabbitMQ broker is reachable before the node proceeds. Future phases
//! will add channel acquisition, queue declaration, and message dispatch.
//!
//! ## Calling Agents
//! - `ipl()` in main.rs — calls `validate()` during the IPL sequence
//!
//! ## Inputs
//! - `BrokerServicesConfig` from the loaded BEDS configuration
//!
//! ## Outputs
//! - `Ok(())` if the broker is reachable
//! - `Err(String)` with host:port and OS error if the broker cannot be reached
//!
//! **Author:** mks
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-02` - mks - original coding
use std::net::TcpStream;
use std::time::Duration;
use crate::config::BrokerServicesConfig;
/// Validates that the RabbitMQ broker is reachable.
///
/// Opens a TCP connection to the configured broker host and port. Does not
/// authenticate or open an AMQP channel — reachability only. The connection
/// is closed immediately after a successful connect.
///
/// # Arguments
///
/// * `cfg` — broker services configuration block from `BedsConfig`
///
/// # Returns
///
/// `Ok(())` if the TCP handshake succeeds.
/// `Err(String)` with a descriptive message if the broker cannot be reached
/// or if the configured address is malformed.
///
/// # History
///
/// * `2026-04-02` - mks - original coding
pub fn validate(cfg: &BrokerServicesConfig) -> Result<(), String> {
let addr_str = format!("{}:{}", cfg.app_server.host, cfg.app_server.port);
let addr: std::net::SocketAddr = addr_str
.parse()
.map_err(|e| format!("Invalid broker address {}: {}", addr_str, e))?;
TcpStream::connect_timeout(&addr, Duration::from_secs(5))
.map_err(|e| format!("RabbitMQ unreachable at {}: {}", addr_str, e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::load_from;
/// Loads the test fixture config. Panics if the fixture is missing or malformed.
fn test_cfg() -> crate::config::BedsConfig {
load_from("tests/fixtures/beds_test.toml", "")
.expect("test fixture beds_test.toml failed to load")
}
#[test]
fn validate_err_on_closed_port() {
// port 1 is reserved and always closed — guarantees a connection refusal
// without requiring any live service
let mut cfg = test_cfg();
cfg.broker_services.app_server.port = 1;
assert!(validate(&cfg.broker_services).is_err());
}
#[test]
fn validate_err_on_bad_address() {
let mut cfg = test_cfg();
cfg.broker_services.app_server.host = "not_a_valid_host!!!".to_string();
assert!(validate(&cfg.broker_services).is_err());
}
}

View File

@@ -1,5 +1,32 @@
//! # config/mod.rs — BEDS Configuration Loader
//!
//! Loads and merges the BEDS configuration from TOML files. The base file
//! (`beds.toml`) holds production-safe defaults; an optional env override
//! file (`env_{name}.toml`) is layered on top. Values in the env file take
//! precedence over the base.
//!
//! ## Calling Agents
//! - `ipl()` in main.rs — calls `load()` as the first IPL step
//! - `tests/common/mod.rs` — calls `load_from()` directly with fixture paths
//!
//! ## Inputs
//! - `BEDS_ENV` environment variable selects the env override file (default: dev)
//! - `base_path`: path to the base config TOML
//! - `env_path`: path to the env override TOML (empty string = no override)
//!
//! ## Outputs
//! - `BedsConfig` — fully-merged, deserialised configuration struct
//! - `ConfigError` on parse or deserialisation failure
//!
//! **Author:** mks
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-02` - mks - original coding
//! * `2026-04-02` - mks - refactored into load() + load_from() for testability
mod structs;
pub use structs::BedsConfig;
pub use structs::{BedsConfig, BrokerServicesConfig};
use config::{Config, File, FileFormat};
@@ -9,14 +36,55 @@ pub enum ConfigError {
LoadError(#[from] config::ConfigError),
}
/// Loads BEDS configuration from explicit file paths.
///
/// Builds the config from a required base file and an optional env override
/// file. If `env_path` is an empty string, no override is applied. This
/// function is the single point of config construction — `load()` delegates
/// to it with the standard production paths.
///
/// # Arguments
///
/// * `base_path` — path to the base TOML config file (required)
/// * `env_path` — path to the env override TOML file; empty string skips it
///
/// # Returns
///
/// `Ok(BedsConfig)` on success.
/// `Err(ConfigError)` if the base file is missing, malformed, or
/// deserialisation fails.
///
/// # History
///
/// * `2026-04-02` - mks - original coding
pub fn load_from(base_path: &str, env_path: &str) -> Result<BedsConfig, ConfigError> {
let mut builder = Config::builder()
.add_source(File::new(base_path, FileFormat::Toml));
if !env_path.is_empty() {
builder = builder.add_source(File::new(env_path, FileFormat::Toml).required(false));
}
Ok(builder.build()?.try_deserialize()?)
}
/// Loads BEDS configuration using the standard production paths.
///
/// Reads `config/beds.toml` as the base, then layers
/// `config/env_{BEDS_ENV}.toml` on top (default env: `dev`).
/// Delegates to `load_from()`.
///
/// # Returns
///
/// `Ok(BedsConfig)` on success.
/// `Err(ConfigError)` if the base file is missing, malformed, or
/// deserialisation fails.
///
/// # History
///
/// * `2026-04-02` - mks - original coding
pub fn load() -> Result<BedsConfig, ConfigError> {
let env = std::env::var("BEDS_ENV").unwrap_or_else(|_| "dev".to_string());
let env_file = format!("config/env_{}.toml", env);
let cfg = Config::builder()
.add_source(File::new("config/beds.toml", FileFormat::Toml))
.add_source(File::new(&env_file, FileFormat::Toml).required(false))
.build()?;
Ok(cfg.try_deserialize()?)
load_from("config/beds.toml", &env_file)
}

18
src/lib.rs Normal file
View File

@@ -0,0 +1,18 @@
//! # lib.rs — BEDS Public API
//!
//! Exposes BEDS modules for use by the integration test harness. All
//! application logic lives in the individual modules; this file re-exports
//! what tests need to reach.
//!
//! ## Calling Agents
//! - `tests/common/mod.rs` and all files under `tests/` — via `rustybeds::`
//!
//! **Author:** mks
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-02` - mks - original coding
pub mod amqp;
pub mod config;
pub mod logging;

View File

@@ -1,10 +1,80 @@
//! # main.rs — BEDS Entry Point
//!
//! Application entry point. Invokes the IPL (Initial Program Load) sequence
//! which bootstraps all required services before the node enters its
//! operational state.
//!
//! ## Calling Agents
//! None — this is the process entry point.
//!
//! ## Inputs
//! - `BEDS_ENV` environment variable selects the env config file (default: dev)
//!
//! ## Outputs
//! - Running BEDS node, blocked on signal handler (not yet implemented)
//! - On fatal IPL failure: error output to console, process exits non-zero
//!
//! **Author:** ms
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-02` - mks - original coding
//! * `2026-04-02` - mks - refactored startup sequence into ipl()
mod amqp;
mod config;
mod logging;
fn main() {
let cfg = config::load().expect("Failed to load config");
/// Executes the BEDS Initial Program Load (IPL) sequence.
///
/// IPL bootstraps the node in strict order. Each step must succeed before
/// the next is attempted. In production environments, any failure is fatal
/// and the process exits with a console error report. In development
/// environments, non-critical failures are logged and IPL continues.
///
/// ## IPL Sequence
/// 1. Load configuration (beds.toml + env override)
/// 2. Initialize logging
/// 3. Connect to required services (AMQP, store adapters) — not yet implemented
/// 4. Declare queues based on node role — not yet implemented
/// 5. Node green
///
/// # Returns
///
/// `Ok(())` if all required services are available and the node is ready.
/// `Err(String)` with a descriptive error message if IPL cannot complete.
///
/// # History
///
/// * `2026-04-02` - mks - original coding
fn ipl() -> Result<(), String> {
// load configuration — fatal in all environments if this fails
let cfg = config::load().map_err(|e| format!("Failed to load config: {}", e))?;
// initialize logging — must come before any tracing calls
logging::init_from_config(cfg.syslog, cfg.syslog_mirror_console);
tracing::info!("BEDS starting, env={}", cfg.id.env_name);
tracing::info!("BEDS IPL starting, node={} env={}", cfg.id.wbid, cfg.id.env_name);
tracing::info!("Configuration loaded");
tracing::info!("Logging initialized");
// validate broker reachability — fatal in production, non-fatal in all other envs
match amqp::validate(&cfg.broker_services) {
Ok(()) => tracing::info!("RabbitMQ reachable"),
Err(e) => {
if cfg.id.env_name == "production" {
return Err(e);
}
tracing::warn!("RabbitMQ unreachable (non-fatal in {}): {}", cfg.id.env_name, e);
}
}
Ok(())
}
fn main() {
if let Err(e) = ipl() {
eprintln!("[BEDS] [FATAL] [IPL] {}", e);
std::process::exit(1);
}
}

39
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,39 @@
//! # tests/common/mod.rs — Shared Test Helpers
//!
//! Provides shared fixtures and helper functions for all BEDS integration
//! tests. Unit tests within source files use the same fixture path directly
//! via `config::load_from()`.
//!
//! ## Usage
//!
//! In any integration test file under `tests/`:
//! ```
//! mod common;
//! let cfg = common::load_test_config();
//! ```
//!
//! **Author:** mks
//! **Version:** 1.0
//!
//! ## History
//! * `2026-04-02` - mks - original coding
/// Path to the canonical test config fixture, relative to the workspace root.
pub const TEST_CONFIG_PATH: &str = "tests/fixtures/beds_test.toml";
/// Loads the canonical BEDS test configuration fixture.
///
/// Panics on failure — a missing or malformed test fixture is a broken test
/// environment, not a recoverable error.
///
/// # Returns
///
/// A fully-deserialised `BedsConfig` from `tests/fixtures/beds_test.toml`.
///
/// # History
///
/// * `2026-04-02` - mks - original coding
pub fn load_test_config() -> rustybeds::config::BedsConfig {
rustybeds::config::load_from(TEST_CONFIG_PATH, "")
.expect("test fixture beds_test.toml failed to load")
}

51
tests/fixtures/beds_test.toml vendored Normal file
View File

@@ -0,0 +1,51 @@
# =============================================================================
# BEDS Test Fixture — beds_test.toml
# =============================================================================
#
# Canonical configuration fixture for all BEDS unit and integration tests.
# Values here are test-safe: localhost services, known ports, no real creds.
#
# Load via tests/common/mod.rs::load_test_config() — do not call
# config::load() directly from tests, as that reads the live config/beds.toml.
#
# AUTHOR: mks
# VERSION: 1.0
#
# HISTORY:
# ========
# 2026-04-02 mks original coding
# =============================================================================
debug = true
syslog = false
syslog_mirror_console = true
audit_on = false
journal_on = false
[id]
env_name = "dev"
version = "1.0"
wbid = "ms"
[broker_services]
queue_tag = "test_"
vhost = "test"
timer_violation = 3000
records_per_xfer = 5000
keepalive = true
heartbeat = 60
use_ssl = false
cert_path = "/etc/rabbitmq"
[broker_services.app_server]
host = "127.0.0.1"
port = 5672
api_port = 15672
user = "beds"
pass = "changeme"
rpi = 50
[broker_services.app_server.instances]
r_broker = 2
w_broker = 2
m_broker = 0