278 lines
8.7 KiB
Rust
Raw Normal View History

2026-03-09 10:18:36 +01:00
use std::{io, sync::Arc};
2026-03-09 08:48:05 +01:00
use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post};
use thiserror::Error;
2026-03-09 10:18:36 +01:00
use crate::repo::{
CfgSyncErrorCode, ConfigProvider, NodeRegistration, RegistrationResponse, RepoResponse,
};
2026-03-09 08:48:05 +01:00
/// Runtime state shared across cfgsync HTTP handlers.
pub struct CfgSyncState {
repo: Arc<dyn ConfigProvider>,
}
impl CfgSyncState {
#[must_use]
pub fn new(repo: Arc<dyn ConfigProvider>) -> Self {
Self { repo }
}
}
/// Fatal runtime failures when serving cfgsync HTTP endpoints.
#[derive(Debug, Error)]
pub enum RunCfgsyncError {
#[error("failed to bind cfgsync server on {bind_addr}: {source}")]
Bind {
bind_addr: String,
#[source]
source: io::Error,
},
#[error("cfgsync server terminated unexpectedly: {source}")]
Serve {
#[source]
source: io::Error,
},
}
async fn node_config(
State(state): State<Arc<CfgSyncState>>,
2026-03-09 10:18:36 +01:00
Json(payload): Json<NodeRegistration>,
2026-03-09 08:48:05 +01:00
) -> impl IntoResponse {
let response = resolve_node_config_response(&state, &payload);
2026-03-09 08:48:05 +01:00
match response {
RepoResponse::Config(payload_data) => (StatusCode::OK, Json(payload_data)).into_response(),
RepoResponse::Error(error) => {
let status = error_status(&error.code);
(status, Json(error)).into_response()
}
}
}
2026-03-09 10:18:36 +01:00
async fn register_node(
State(state): State<Arc<CfgSyncState>>,
Json(payload): Json<NodeRegistration>,
) -> impl IntoResponse {
match state.repo.register(payload) {
RegistrationResponse::Registered => StatusCode::ACCEPTED.into_response(),
RegistrationResponse::Error(error) => {
let status = error_status(&error.code);
(status, Json(error)).into_response()
}
}
}
fn resolve_node_config_response(
state: &CfgSyncState,
registration: &NodeRegistration,
) -> RepoResponse {
state.repo.resolve(registration)
2026-03-09 08:48:05 +01:00
}
fn error_status(code: &CfgSyncErrorCode) -> StatusCode {
match code {
CfgSyncErrorCode::MissingConfig => StatusCode::NOT_FOUND,
2026-03-09 10:18:36 +01:00
CfgSyncErrorCode::NotReady => StatusCode::TOO_EARLY,
2026-03-09 08:48:05 +01:00
CfgSyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn cfgsync_app(state: CfgSyncState) -> Router {
Router::new()
2026-03-09 10:18:36 +01:00
.route("/register", post(register_node))
2026-03-09 08:48:05 +01:00
.route("/node", post(node_config))
.route("/init-with-node", post(node_config))
.with_state(Arc::new(state))
}
/// Runs cfgsync HTTP server on the provided port until shutdown/error.
pub async fn run_cfgsync(port: u16, state: CfgSyncState) -> Result<(), RunCfgsyncError> {
let app = cfgsync_app(state);
println!("Server running on http://0.0.0.0:{port}");
let bind_addr = format!("0.0.0.0:{port}");
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.map_err(|source| RunCfgsyncError::Bind { bind_addr, source })?;
axum::serve(listener, app)
.await
.map_err(|source| RunCfgsyncError::Serve { source })?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
2026-03-09 10:18:36 +01:00
use super::{CfgSyncState, NodeRegistration, node_config, register_node};
2026-03-09 08:48:05 +01:00
use crate::repo::{
CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile,
2026-03-09 10:18:36 +01:00
CfgSyncPayload, ConfigProvider, RegistrationResponse, RepoResponse,
2026-03-09 08:48:05 +01:00
};
struct StaticProvider {
data: HashMap<String, CfgSyncPayload>,
}
impl ConfigProvider for StaticProvider {
2026-03-09 10:18:36 +01:00
fn register(&self, registration: NodeRegistration) -> RegistrationResponse {
if self.data.contains_key(&registration.identifier) {
RegistrationResponse::Registered
} else {
RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(
&registration.identifier,
))
}
}
fn resolve(&self, registration: &NodeRegistration) -> RepoResponse {
self.data
.get(&registration.identifier)
.cloned()
.map_or_else(
|| {
RepoResponse::Error(CfgSyncErrorResponse::missing_config(
&registration.identifier,
))
},
RepoResponse::Config,
)
}
}
struct RegistrationAwareProvider {
data: HashMap<String, CfgSyncPayload>,
registrations: std::sync::Mutex<HashMap<String, NodeRegistration>>,
}
impl ConfigProvider for RegistrationAwareProvider {
fn register(&self, registration: NodeRegistration) -> RegistrationResponse {
if !self.data.contains_key(&registration.identifier) {
return RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(
&registration.identifier,
));
}
let mut registrations = self
.registrations
.lock()
.expect("test registration store should not be poisoned");
registrations.insert(registration.identifier.clone(), registration);
RegistrationResponse::Registered
}
fn resolve(&self, registration: &NodeRegistration) -> RepoResponse {
let registrations = self
.registrations
.lock()
.expect("test registration store should not be poisoned");
if !registrations.contains_key(&registration.identifier) {
return RepoResponse::Error(CfgSyncErrorResponse::not_ready(
&registration.identifier,
));
}
self.data
.get(&registration.identifier)
.cloned()
.map_or_else(
|| {
RepoResponse::Error(CfgSyncErrorResponse::missing_config(
&registration.identifier,
))
},
RepoResponse::Config,
)
2026-03-09 08:48:05 +01:00
}
}
fn sample_payload() -> CfgSyncPayload {
CfgSyncPayload {
schema_version: CFGSYNC_SCHEMA_VERSION,
files: vec![CfgSyncFile::new("/app-config.yaml", "app: test")],
}
}
#[tokio::test]
async fn node_config_resolves_from_non_tf_provider() {
let mut data = HashMap::new();
data.insert("node-a".to_owned(), sample_payload());
let provider = Arc::new(RegistrationAwareProvider {
data,
registrations: std::sync::Mutex::new(HashMap::new()),
});
2026-03-09 08:48:05 +01:00
let state = Arc::new(CfgSyncState::new(provider));
2026-03-09 10:18:36 +01:00
let payload = NodeRegistration {
2026-03-09 08:48:05 +01:00
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "node-a".to_owned(),
};
2026-03-09 10:18:36 +01:00
let _ = register_node(State(state.clone()), Json(payload.clone()))
.await
.into_response();
2026-03-09 08:48:05 +01:00
let response = node_config(State(state), Json(payload))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn node_config_returns_not_found_for_unknown_identifier() {
let provider = Arc::new(StaticProvider {
data: HashMap::new(),
});
let state = Arc::new(CfgSyncState::new(provider));
2026-03-09 10:18:36 +01:00
let payload = NodeRegistration {
2026-03-09 08:48:05 +01:00
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "missing-node".to_owned(),
};
let response = node_config(State(state), Json(payload))
.await
.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn missing_config_error_uses_expected_code() {
let error = CfgSyncErrorResponse::missing_config("missing-node");
assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig));
}
2026-03-09 10:18:36 +01:00
#[tokio::test]
async fn node_config_returns_not_ready_before_registration() {
let mut data = HashMap::new();
data.insert("node-a".to_owned(), sample_payload());
let provider = Arc::new(RegistrationAwareProvider {
data,
registrations: std::sync::Mutex::new(HashMap::new()),
});
2026-03-09 10:18:36 +01:00
let state = Arc::new(CfgSyncState::new(provider));
let payload = NodeRegistration {
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "node-a".to_owned(),
};
let response = node_config(State(state), Json(payload))
.await
.into_response();
assert_eq!(response.status(), StatusCode::TOO_EARLY);
}
2026-03-09 08:48:05 +01:00
}