Make cfgsync registration metadata generic

This commit is contained in:
andrussal 2026-03-10 09:41:03 +01:00
parent b775f7fd81
commit 80e1fe6c66
7 changed files with 184 additions and 46 deletions

1
Cargo.lock generated
View File

@ -959,6 +959,7 @@ dependencies = [
"cfgsync-core", "cfgsync-core",
"clap", "clap",
"serde", "serde",
"serde_json",
"serde_yaml", "serde_yaml",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",

View File

@ -308,10 +308,7 @@ mod tests {
files: vec![ArtifactFile::new("/config.yaml", "key: value")], files: vec![ArtifactFile::new("/config.yaml", "key: value")],
}]); }]);
let provider = MaterializingConfigProvider::new(catalog); let provider = MaterializingConfigProvider::new(catalog);
let registration = NodeRegistration { let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip"));
identifier: "node-1".to_owned(),
ip: "127.0.0.1".parse().expect("parse ip"),
};
let _ = provider.register(registration.clone()); let _ = provider.register(registration.clone());
@ -328,10 +325,7 @@ mod tests {
files: vec![ArtifactFile::new("/config.yaml", "key: value")], files: vec![ArtifactFile::new("/config.yaml", "key: value")],
}]); }]);
let provider = MaterializingConfigProvider::new(catalog); let provider = MaterializingConfigProvider::new(catalog);
let registration = NodeRegistration { let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip"));
identifier: "node-1".to_owned(),
ip: "127.0.0.1".parse().expect("parse ip"),
};
match provider.resolve(&registration) { match provider.resolve(&registration) {
RepoResponse::Config(_) => panic!("expected not-ready error"), RepoResponse::Config(_) => panic!("expected not-ready error"),

View File

@ -14,6 +14,6 @@ pub use render::{
pub use repo::{ pub use repo::{
CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload,
ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration, ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration,
RegistrationResponse, RepoResponse, RegistrationMetadata, RegistrationResponse, RepoResponse,
}; };
pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync}; pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync};

View File

@ -2,6 +2,7 @@ use std::{collections::HashMap, fs, net::Ipv4Addr, path::Path, sync::Arc};
use cfgsync_artifacts::ArtifactFile; use cfgsync_artifacts::ArtifactFile;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error; use thiserror::Error;
use crate::{CfgSyncBundle, CfgSyncBundleNode}; use crate::{CfgSyncBundle, CfgSyncBundleNode};
@ -22,11 +23,84 @@ pub struct CfgSyncPayload {
pub files: Vec<CfgSyncFile>, pub files: Vec<CfgSyncFile>,
} }
/// Adapter-owned registration metadata stored alongside a generic node
/// identity.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct RegistrationMetadata {
values: Map<String, Value>,
}
impl RegistrationMetadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&Value> {
self.values.get(key)
}
pub fn insert_json_value(&mut self, key: impl Into<String>, value: Value) {
self.values.insert(key.into(), value);
}
pub fn insert_serialized<T>(
&mut self,
key: impl Into<String>,
value: T,
) -> Result<(), serde_json::Error>
where
T: Serialize,
{
let value = serde_json::to_value(value)?;
self.insert_json_value(key, value);
Ok(())
}
#[must_use]
pub fn values(&self) -> &Map<String, Value> {
&self.values
}
}
impl From<Map<String, Value>> for RegistrationMetadata {
fn from(values: Map<String, Value>) -> Self {
Self { values }
}
}
/// Node metadata recorded before config materialization. /// Node metadata recorded before config materialization.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeRegistration { pub struct NodeRegistration {
pub identifier: String, pub identifier: String,
pub ip: Ipv4Addr, pub ip: Ipv4Addr,
#[serde(default, skip_serializing_if = "RegistrationMetadata::is_empty")]
pub metadata: RegistrationMetadata,
}
impl NodeRegistration {
#[must_use]
pub fn new(identifier: impl Into<String>, ip: Ipv4Addr) -> Self {
Self {
identifier: identifier.into(),
ip,
metadata: RegistrationMetadata::default(),
}
}
#[must_use]
pub fn with_metadata(mut self, metadata: RegistrationMetadata) -> Self {
self.metadata = metadata;
self
}
} }
impl CfgSyncPayload { impl CfgSyncPayload {
@ -228,10 +302,10 @@ mod tests {
configs.insert("node-1".to_owned(), sample_payload()); configs.insert("node-1".to_owned(), sample_payload());
let repo = ConfigRepo { configs }; let repo = ConfigRepo { configs };
match repo.resolve(&NodeRegistration { match repo.resolve(&NodeRegistration::new(
identifier: "node-1".to_owned(), "node-1",
ip: "127.0.0.1".parse().expect("parse ip"), "127.0.0.1".parse().expect("parse ip"),
}) { )) {
RepoResponse::Config(payload) => { RepoResponse::Config(payload) => {
assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION);
assert_eq!(payload.files.len(), 1); assert_eq!(payload.files.len(), 1);
@ -247,10 +321,10 @@ mod tests {
configs: HashMap::new(), configs: HashMap::new(),
}; };
match repo.resolve(&NodeRegistration { match repo.resolve(&NodeRegistration::new(
identifier: "unknown-node".to_owned(), "unknown-node",
ip: "127.0.0.1".parse().expect("parse ip"), "127.0.0.1".parse().expect("parse ip"),
}) { )) {
RepoResponse::Config(_) => panic!("expected missing-config error"), RepoResponse::Config(_) => panic!("expected missing-config error"),
RepoResponse::Error(error) => { RepoResponse::Error(error) => {
assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig));
@ -276,15 +350,15 @@ nodes:
let provider = let provider =
FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider"); FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider");
let _ = provider.register(NodeRegistration { let _ = provider.register(NodeRegistration::new(
identifier: "node-1".to_owned(), "node-1",
ip: "127.0.0.1".parse().expect("parse ip"), "127.0.0.1".parse().expect("parse ip"),
}); ));
match provider.resolve(&NodeRegistration { match provider.resolve(&NodeRegistration::new(
identifier: "node-1".to_owned(), "node-1",
ip: "127.0.0.1".parse().expect("parse ip"), "127.0.0.1".parse().expect("parse ip"),
}) { )) {
RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1),
RepoResponse::Error(error) => panic!("expected config, got {error}"), RepoResponse::Error(error) => panic!("expected config, got {error}"),
} }
@ -296,12 +370,39 @@ nodes:
configs.insert("node-1".to_owned(), sample_payload()); configs.insert("node-1".to_owned(), sample_payload());
let repo = ConfigRepo { configs }; let repo = ConfigRepo { configs };
match repo.resolve(&NodeRegistration { match repo.resolve(&NodeRegistration::new(
identifier: "node-1".to_owned(), "node-1",
ip: "127.0.0.1".parse().expect("parse ip"), "127.0.0.1".parse().expect("parse ip"),
}) { )) {
RepoResponse::Config(_) => {} RepoResponse::Config(_) => {}
RepoResponse::Error(error) => panic!("expected config, got {error}"), RepoResponse::Error(error) => panic!("expected config, got {error}"),
} }
} }
#[test]
fn registration_metadata_serializes_as_object() {
let mut metadata = RegistrationMetadata::new();
metadata
.insert_serialized("network_port", 3000_u16)
.expect("serialize metadata");
metadata.insert_json_value("service", Value::String("blend".to_owned()));
let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip"))
.with_metadata(metadata);
let encoded = serde_json::to_value(&registration).expect("serialize registration");
let metadata = encoded
.get("metadata")
.and_then(Value::as_object)
.expect("registration metadata object");
assert_eq!(
metadata.get("network_port"),
Some(&Value::Number(3000_u16.into()))
);
assert_eq!(
metadata.get("service"),
Some(&Value::String("blend".to_owned()))
);
}
} }

View File

@ -212,10 +212,7 @@ mod tests {
registrations: std::sync::Mutex::new(HashMap::new()), registrations: std::sync::Mutex::new(HashMap::new()),
}); });
let state = Arc::new(CfgSyncState::new(provider)); let state = Arc::new(CfgSyncState::new(provider));
let payload = NodeRegistration { let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip"));
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "node-a".to_owned(),
};
let _ = register_node(State(state.clone()), Json(payload.clone())) let _ = register_node(State(state.clone()), Json(payload.clone()))
.await .await
@ -234,10 +231,7 @@ mod tests {
data: HashMap::new(), data: HashMap::new(),
}); });
let state = Arc::new(CfgSyncState::new(provider)); let state = Arc::new(CfgSyncState::new(provider));
let payload = NodeRegistration { let payload = NodeRegistration::new("missing-node", "127.0.0.1".parse().expect("valid ip"));
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "missing-node".to_owned(),
};
let response = node_config(State(state), Json(payload)) let response = node_config(State(state), Json(payload))
.await .await
@ -263,10 +257,7 @@ mod tests {
registrations: std::sync::Mutex::new(HashMap::new()), registrations: std::sync::Mutex::new(HashMap::new()),
}); });
let state = Arc::new(CfgSyncState::new(provider)); let state = Arc::new(CfgSyncState::new(provider));
let payload = NodeRegistration { let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip"));
ip: "127.0.0.1".parse().expect("valid ip"),
identifier: "node-a".to_owned(),
};
let response = node_config(State(state), Json(payload)) let response = node_config(State(state), Json(payload))
.await .await

View File

@ -18,6 +18,7 @@ cfgsync-adapter = { workspace = true }
cfgsync-core = { workspace = true } cfgsync-core = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" }

View File

@ -7,7 +7,9 @@ use std::{
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use cfgsync_core::{ use cfgsync_core::{
CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration, CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration,
RegistrationMetadata,
}; };
use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
use tracing::info; use tracing::info;
@ -19,6 +21,8 @@ const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250);
enum ClientEnvError { enum ClientEnvError {
#[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")]
InvalidIp { value: String }, InvalidIp { value: String },
#[error("CFG_REGISTRATION_METADATA_JSON must be a JSON object")]
InvalidRegistrationMetadataShape,
} }
async fn fetch_with_retry(payload: &NodeRegistration, server_addr: &str) -> Result<CfgSyncPayload> { async fn fetch_with_retry(payload: &NodeRegistration, server_addr: &str) -> Result<CfgSyncPayload> {
@ -145,8 +149,13 @@ pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> {
let ip = parse_ip_env(&env::var("CFG_HOST_IP").unwrap_or_else(|_| "127.0.0.1".to_owned()))?; let ip = parse_ip_env(&env::var("CFG_HOST_IP").unwrap_or_else(|_| "127.0.0.1".to_owned()))?;
let identifier = let identifier =
env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned());
let metadata = parse_registration_metadata_env()?;
pull_config_files(NodeRegistration { ip, identifier }, &server_addr).await pull_config_files(
NodeRegistration::new(identifier, ip).with_metadata(metadata),
&server_addr,
)
.await
} }
fn parse_ip_env(ip_str: &str) -> Result<Ipv4Addr> { fn parse_ip_env(ip_str: &str) -> Result<Ipv4Addr> {
@ -158,6 +167,24 @@ fn parse_ip_env(ip_str: &str) -> Result<Ipv4Addr> {
.map_err(Into::into) .map_err(Into::into)
} }
fn parse_registration_metadata_env() -> Result<RegistrationMetadata> {
let Ok(raw) = env::var("CFG_REGISTRATION_METADATA_JSON") else {
return Ok(RegistrationMetadata::default());
};
parse_registration_metadata(&raw)
}
fn parse_registration_metadata(raw: &str) -> Result<RegistrationMetadata> {
let value: Value =
serde_json::from_str(raw).context("parsing CFG_REGISTRATION_METADATA_JSON")?;
let Some(metadata) = value.as_object() else {
return Err(ClientEnvError::InvalidRegistrationMetadataShape.into());
};
Ok(RegistrationMetadata::from(metadata.clone()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
@ -192,10 +219,7 @@ mod tests {
}); });
pull_config_files( pull_config_files(
NodeRegistration { NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")),
ip: "127.0.0.1".parse().expect("parse ip"),
identifier: "node-1".to_owned(),
},
&address, &address,
) )
.await .await
@ -230,4 +254,30 @@ mod tests {
drop(listener); drop(listener);
port port
} }
#[test]
fn parses_registration_metadata_object() {
let metadata = parse_registration_metadata(r#"{"network_port":3000,"service":"blend"}"#)
.expect("parse metadata");
assert_eq!(
metadata.get("network_port"),
Some(&Value::Number(3000_u16.into()))
);
assert_eq!(
metadata.get("service"),
Some(&Value::String("blend".to_owned()))
);
}
#[test]
fn rejects_non_object_registration_metadata() {
let error = parse_registration_metadata(r#"[1,2,3]"#).expect_err("reject metadata array");
assert!(
error
.to_string()
.contains("CFG_REGISTRATION_METADATA_JSON must be a JSON object")
);
}
} }