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 {
|
2026-03-10 08:57:41 +01:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 08:57:41 +01:00
|
|
|
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(®istration.identifier) {
|
|
|
|
|
RegistrationResponse::Registered
|
|
|
|
|
} else {
|
|
|
|
|
RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(
|
|
|
|
|
®istration.identifier,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 08:57:41 +01:00
|
|
|
fn resolve(&self, registration: &NodeRegistration) -> RepoResponse {
|
|
|
|
|
self.data
|
|
|
|
|
.get(®istration.identifier)
|
|
|
|
|
.cloned()
|
|
|
|
|
.map_or_else(
|
|
|
|
|
|| {
|
|
|
|
|
RepoResponse::Error(CfgSyncErrorResponse::missing_config(
|
|
|
|
|
®istration.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(®istration.identifier) {
|
|
|
|
|
return RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(
|
|
|
|
|
®istration.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(®istration.identifier) {
|
|
|
|
|
return RepoResponse::Error(CfgSyncErrorResponse::not_ready(
|
|
|
|
|
®istration.identifier,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.data
|
|
|
|
|
.get(®istration.identifier)
|
|
|
|
|
.cloned()
|
|
|
|
|
.map_or_else(
|
|
|
|
|
|| {
|
|
|
|
|
RepoResponse::Error(CfgSyncErrorResponse::missing_config(
|
|
|
|
|
®istration.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());
|
|
|
|
|
|
2026-03-10 08:57:41 +01:00
|
|
|
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-10 09:41:03 +01:00
|
|
|
let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip"));
|
2026-03-09 08:48:05 +01:00
|
|
|
|
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-10 09:41:03 +01:00
|
|
|
let payload = NodeRegistration::new("missing-node", "127.0.0.1".parse().expect("valid ip"));
|
2026-03-09 08:48:05 +01:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
2026-03-10 08:57:41 +01:00
|
|
|
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));
|
2026-03-10 09:41:03 +01:00
|
|
|
let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip"));
|
2026-03-09 10:18:36 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
}
|