From 7da3df455f5620e53bed4dccce02027859cdbeaf Mon Sep 17 00:00:00 2001 From: andrussal Date: Mon, 9 Mar 2026 08:48:05 +0100 Subject: [PATCH 01/38] Extract cfgsync into standalone crates --- Cargo.lock | 26 +- Cargo.toml | 13 +- cfgsync/adapter/Cargo.toml | 16 ++ cfgsync/adapter/src/lib.rs | 119 +++++++++ cfgsync/artifacts/Cargo.toml | 17 ++ cfgsync/artifacts/src/lib.rs | 64 +++++ cfgsync/core/Cargo.toml | 27 ++ cfgsync/core/src/bundle.rs | 26 ++ .../core}/src/client.rs | 5 + cfgsync/core/src/lib.rs | 18 ++ .../core}/src/render.rs | 29 ++- cfgsync/core/src/repo.rs | 233 ++++++++++++++++++ cfgsync/core/src/server.rs | 172 +++++++++++++ cfgsync/runtime/Cargo.toml | 26 ++ .../runtime}/src/bin/cfgsync-client.rs | 0 .../runtime}/src/bin/cfgsync-server.rs | 0 cfgsync/runtime/src/client.rs | 205 +++++++++++++++ .../runtime}/src/lib.rs | 3 - cfgsync/runtime/src/server.rs | 59 +++++ .../stack/scripts/docker/build_cfgsync.sh | 4 +- logos/runtime/ext/Cargo.toml | 2 +- logos/runtime/ext/src/cfgsync/mod.rs | 47 +++- logos/runtime/ext/src/compose_env.rs | 1 - logos/runtime/ext/src/k8s_env.rs | 2 +- testing-framework/core/Cargo.toml | 1 + testing-framework/core/src/cfgsync/mod.rs | 85 +------ testing-framework/core/src/lib.rs | 4 + .../deployers/compose/Cargo.toml | 1 + .../compose/assets/docker-compose.yml.tera | 3 - .../deployers/compose/src/descriptor/node.rs | 8 - testing-framework/deployers/k8s/src/env.rs | 3 +- .../tools/cfgsync-core/Cargo.toml | 21 -- .../tools/cfgsync-core/src/lib.rs | 10 - .../tools/cfgsync-core/src/repo.rs | 107 -------- .../tools/cfgsync-core/src/server.rs | 95 ------- .../tools/cfgsync-runtime/Cargo.toml | 22 -- .../tools/cfgsync-runtime/src/bundle.rs | 39 --- .../tools/cfgsync-runtime/src/client.rs | 108 -------- .../tools/cfgsync-runtime/src/server.rs | 101 -------- 39 files changed, 1095 insertions(+), 627 deletions(-) create mode 100644 cfgsync/adapter/Cargo.toml create mode 100644 cfgsync/adapter/src/lib.rs create mode 100644 cfgsync/artifacts/Cargo.toml create mode 100644 cfgsync/artifacts/src/lib.rs create mode 100644 cfgsync/core/Cargo.toml create mode 100644 cfgsync/core/src/bundle.rs rename {testing-framework/tools/cfgsync-core => cfgsync/core}/src/client.rs (88%) create mode 100644 cfgsync/core/src/lib.rs rename {testing-framework/tools/cfgsync-runtime => cfgsync/core}/src/render.rs (77%) create mode 100644 cfgsync/core/src/repo.rs create mode 100644 cfgsync/core/src/server.rs create mode 100644 cfgsync/runtime/Cargo.toml rename {testing-framework/tools/cfgsync-runtime => cfgsync/runtime}/src/bin/cfgsync-client.rs (100%) rename {testing-framework/tools/cfgsync-runtime => cfgsync/runtime}/src/bin/cfgsync-server.rs (100%) create mode 100644 cfgsync/runtime/src/client.rs rename {testing-framework/tools/cfgsync-runtime => cfgsync/runtime}/src/lib.rs (82%) create mode 100644 cfgsync/runtime/src/server.rs delete mode 100644 testing-framework/tools/cfgsync-core/Cargo.toml delete mode 100644 testing-framework/tools/cfgsync-core/src/lib.rs delete mode 100644 testing-framework/tools/cfgsync-core/src/repo.rs delete mode 100644 testing-framework/tools/cfgsync-core/src/server.rs delete mode 100644 testing-framework/tools/cfgsync-runtime/Cargo.toml delete mode 100644 testing-framework/tools/cfgsync-runtime/src/bundle.rs delete mode 100644 testing-framework/tools/cfgsync-runtime/src/client.rs delete mode 100644 testing-framework/tools/cfgsync-runtime/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 0145d09..0d00c93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -916,14 +916,33 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "cfgsync-adapter" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "cfgsync-artifacts" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + [[package]] name = "cfgsync-core" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "cfgsync-artifacts", "reqwest", "serde", "serde_json", + "serde_yaml", + "tempfile", "thiserror 2.0.18", "tokio", ] @@ -937,8 +956,10 @@ dependencies = [ "clap", "serde", "serde_yaml", - "testing-framework-core", + "tempfile", + "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] @@ -2891,8 +2912,8 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "cfgsync-adapter", "cfgsync-core", - "cfgsync-runtime", "kube", "logos-blockchain-http-api-common", "reqwest", @@ -6528,6 +6549,7 @@ name = "testing-framework-core" version = "0.1.0" dependencies = [ "async-trait", + "cfgsync-adapter", "futures", "parking_lot", "prometheus-http-query", diff --git a/Cargo.toml b/Cargo.toml index 9e32eb1..a1f960d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] members = [ + "cfgsync/adapter", + "cfgsync/artifacts", + "cfgsync/core", + "cfgsync/runtime", "logos/examples", "logos/runtime/env", "logos/runtime/ext", @@ -8,8 +12,6 @@ members = [ "testing-framework/deployers/compose", "testing-framework/deployers/k8s", "testing-framework/deployers/local", - "testing-framework/tools/cfgsync-core", - "testing-framework/tools/cfgsync-runtime", ] resolver = "2" @@ -31,7 +33,9 @@ all = "allow" [workspace.dependencies] # Local testing framework crates -cfgsync-core = { default-features = false, path = "testing-framework/tools/cfgsync-core" } +cfgsync-adapter = { default-features = false, path = "cfgsync/adapter" } +cfgsync-artifacts = { default-features = false, path = "cfgsync/artifacts" } +cfgsync-core = { default-features = false, path = "cfgsync/core" } lb-ext = { default-features = false, path = "logos/runtime/ext" } lb-framework = { default-features = false, package = "testing_framework", git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "5ebe88a6e89ec6d7dd89e123c46f6b26dd1e4667" } lb-workloads = { default-features = false, path = "logos/runtime/workloads" } @@ -40,10 +44,11 @@ testing-framework-env = { default-features = false, path = "logos/run testing-framework-runner-compose = { default-features = false, path = "testing-framework/deployers/compose" } testing-framework-runner-k8s = { default-features = false, path = "testing-framework/deployers/k8s" } testing-framework-runner-local = { default-features = false, path = "testing-framework/deployers/local" } +testing-framework-workflows = { default-features = false, package = "lb-workloads", path = "logos/runtime/workloads" } # Logos dependencies (from logos-blockchain master @ deccbb2d2) broadcast-service = { default-features = false, package = "logos-blockchain-chain-broadcast-service", git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "5ebe88a6e89ec6d7dd89e123c46f6b26dd1e4667" } -cfgsync_runtime = { default-features = false, package = "cfgsync-runtime", path = "testing-framework/tools/cfgsync-runtime" } +cfgsync_runtime = { default-features = false, package = "cfgsync-runtime", path = "cfgsync/runtime" } chain-leader = { default-features = false, features = [ "pol-dev-mode", ], package = "logos-blockchain-chain-leader-service", git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "5ebe88a6e89ec6d7dd89e123c46f6b26dd1e4667" } diff --git a/cfgsync/adapter/Cargo.toml b/cfgsync/adapter/Cargo.toml new file mode 100644 index 0000000..034b349 --- /dev/null +++ b/cfgsync/adapter/Cargo.toml @@ -0,0 +1,16 @@ +[package] +categories = { workspace = true } +description = { workspace = true } +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +name = "cfgsync-adapter" +readme = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs new file mode 100644 index 0000000..467630d --- /dev/null +++ b/cfgsync/adapter/src/lib.rs @@ -0,0 +1,119 @@ +use std::error::Error; + +use thiserror::Error; + +/// Type-erased cfgsync adapter error used to preserve source context. +pub type DynCfgsyncError = Box; + +/// Per-node rendered config output used to build cfgsync bundles. +#[derive(Debug, Clone)] +pub struct CfgsyncNodeConfig { + /// Stable node identifier resolved by the adapter. + pub identifier: String, + /// Serialized config payload for the node. + pub config_yaml: String, +} + +/// Adapter contract for converting an application deployment model into +/// node-specific serialized config payloads. +pub trait CfgsyncEnv { + type Deployment; + type Node; + type NodeConfig; + type Error: Error + Send + Sync + 'static; + + fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; + + fn node_identifier(index: usize, node: &Self::Node) -> String; + + fn build_node_config( + deployment: &Self::Deployment, + node: &Self::Node, + ) -> Result; + + fn rewrite_for_hostnames( + deployment: &Self::Deployment, + node_index: usize, + hostnames: &[String], + config: &mut Self::NodeConfig, + ) -> Result<(), Self::Error>; + + fn serialize_node_config(config: &Self::NodeConfig) -> Result; +} + +/// High-level failures while building adapter output for cfgsync. +#[derive(Debug, Error)] +pub enum BuildCfgsyncNodesError { + #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] + HostnameCountMismatch { nodes: usize, hostnames: usize }, + #[error("cfgsync adapter failed: {source}")] + Adapter { + #[source] + source: DynCfgsyncError, + }, +} + +fn adapter_error(source: E) -> BuildCfgsyncNodesError +where + E: Error + Send + Sync + 'static, +{ + BuildCfgsyncNodesError::Adapter { + source: Box::new(source), + } +} + +/// Builds cfgsync node configs for a deployment by: +/// 1) validating hostname count, +/// 2) building each node config, +/// 3) rewriting host references, +/// 4) serializing each node payload. +pub fn build_cfgsync_node_configs( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result, BuildCfgsyncNodesError> { + let nodes = E::nodes(deployment); + ensure_hostname_count(nodes.len(), hostnames.len())?; + + let mut output = Vec::with_capacity(nodes.len()); + for (index, node) in nodes.iter().enumerate() { + output.push(build_node_entry::(deployment, node, index, hostnames)?); + } + + Ok(output) +} + +fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { + if nodes != hostnames { + return Err(BuildCfgsyncNodesError::HostnameCountMismatch { nodes, hostnames }); + } + + Ok(()) +} + +fn build_node_entry( + deployment: &E::Deployment, + node: &E::Node, + index: usize, + hostnames: &[String], +) -> Result { + let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; + let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; + + Ok(CfgsyncNodeConfig { + identifier: E::node_identifier(index, node), + config_yaml, + }) +} + +fn build_rewritten_node_config( + deployment: &E::Deployment, + node: &E::Node, + index: usize, + hostnames: &[String], +) -> Result { + let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; + E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) + .map_err(adapter_error)?; + + Ok(node_config) +} diff --git a/cfgsync/artifacts/Cargo.toml b/cfgsync/artifacts/Cargo.toml new file mode 100644 index 0000000..7c2db1e --- /dev/null +++ b/cfgsync/artifacts/Cargo.toml @@ -0,0 +1,17 @@ +[package] +categories = { workspace = true } +description = "App-agnostic cfgsync artifact model" +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +name = "cfgsync-artifacts" +readme = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/cfgsync/artifacts/src/lib.rs b/cfgsync/artifacts/src/lib.rs new file mode 100644 index 0000000..2d1b54c --- /dev/null +++ b/cfgsync/artifacts/src/lib.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Single file artifact delivered to a node. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactFile { + /// Destination path where content should be written. + pub path: String, + /// Raw file contents. + pub content: String, +} + +impl ArtifactFile { + #[must_use] + pub fn new(path: impl Into, content: impl Into) -> Self { + Self { + path: path.into(), + content: content.into(), + } + } +} + +/// Collection of files delivered together for one node. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ArtifactSet { + pub files: Vec, +} + +impl ArtifactSet { + #[must_use] + pub fn new(files: Vec) -> Self { + Self { files } + } + + #[must_use] + pub fn len(&self) -> usize { + self.files.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + /// Validates that no two files target the same output path. + pub fn ensure_unique_paths(&self) -> Result<(), ArtifactValidationError> { + let mut seen = std::collections::HashSet::new(); + + for file in &self.files { + if !seen.insert(file.path.clone()) { + return Err(ArtifactValidationError::DuplicatePath(file.path.clone())); + } + } + + Ok(()) + } +} + +/// Validation failures for [`ArtifactSet`]. +#[derive(Debug, Error)] +pub enum ArtifactValidationError { + #[error("duplicate artifact path `{0}`")] + DuplicatePath(String), +} diff --git a/cfgsync/core/Cargo.toml b/cfgsync/core/Cargo.toml new file mode 100644 index 0000000..fea2c39 --- /dev/null +++ b/cfgsync/core/Cargo.toml @@ -0,0 +1,27 @@ +[package] +categories = { workspace = true } +description = { workspace = true } +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +name = "cfgsync-core" +readme = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +axum = { default-features = false, features = ["http1", "http2", "json", "tokio"], version = "0.7.5" } +cfgsync-artifacts = { workspace = true } +reqwest = { features = ["json"], workspace = true } +serde = { default-features = false, features = ["derive"], version = "1" } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/cfgsync/core/src/bundle.rs b/cfgsync/core/src/bundle.rs new file mode 100644 index 0000000..003dd8a --- /dev/null +++ b/cfgsync/core/src/bundle.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +use crate::CfgSyncFile; + +/// Top-level cfgsync bundle containing per-node file payloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfgSyncBundle { + pub nodes: Vec, +} + +impl CfgSyncBundle { + #[must_use] + pub fn new(nodes: Vec) -> Self { + Self { nodes } + } +} + +/// Artifact set for a single node resolved by identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfgSyncBundleNode { + /// Stable node identifier used by cfgsync lookup. + pub identifier: String, + /// Files that should be materialized for the node. + #[serde(default)] + pub files: Vec, +} diff --git a/testing-framework/tools/cfgsync-core/src/client.rs b/cfgsync/core/src/client.rs similarity index 88% rename from testing-framework/tools/cfgsync-core/src/client.rs rename to cfgsync/core/src/client.rs index 2df652c..28e59b5 100644 --- a/testing-framework/tools/cfgsync-core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -6,6 +6,7 @@ use crate::{ server::ClientIp, }; +/// cfgsync client-side request/response failures. #[derive(Debug, Error)] pub enum ClientError { #[error("request failed: {0}")] @@ -20,6 +21,7 @@ pub enum ClientError { Decode(serde_json::Error), } +/// Reusable HTTP client for cfgsync server endpoints. #[derive(Clone, Debug)] pub struct CfgSyncClient { base_url: String, @@ -44,6 +46,7 @@ impl CfgSyncClient { &self.base_url } + /// Fetches `/node` payload for a node identifier. pub async fn fetch_node_config( &self, payload: &ClientIp, @@ -51,6 +54,7 @@ impl CfgSyncClient { self.post_json("/node", payload).await } + /// Fetches `/init-with-node` payload for a node identifier. pub async fn fetch_init_with_node_config( &self, payload: &ClientIp, @@ -58,6 +62,7 @@ impl CfgSyncClient { self.post_json("/init-with-node", payload).await } + /// Posts JSON payload to a cfgsync endpoint and decodes cfgsync payload. pub async fn post_json( &self, path: &str, diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs new file mode 100644 index 0000000..b6851e3 --- /dev/null +++ b/cfgsync/core/src/lib.rs @@ -0,0 +1,18 @@ +pub mod bundle; +pub mod client; +pub mod render; +pub mod repo; +pub mod server; + +pub use bundle::{CfgSyncBundle, CfgSyncBundleNode}; +pub use client::{CfgSyncClient, ClientError}; +pub use render::{ + CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, + apply_timeout_floor, ensure_bundle_path, load_cfgsync_template_yaml, + render_cfgsync_yaml_from_template, write_rendered_cfgsync, +}; +pub use repo::{ + CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, + ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, RepoResponse, +}; +pub use server::{CfgSyncState, ClientIp, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/testing-framework/tools/cfgsync-runtime/src/render.rs b/cfgsync/core/src/render.rs similarity index 77% rename from testing-framework/tools/cfgsync-runtime/src/render.rs rename to cfgsync/core/src/render.rs index 0f59b5c..2031986 100644 --- a/testing-framework/tools/cfgsync-runtime/src/render.rs +++ b/cfgsync/core/src/render.rs @@ -2,19 +2,25 @@ use std::{fs, path::Path}; use anyhow::{Context as _, Result}; use serde_yaml::{Mapping, Value}; +use thiserror::Error; +/// Rendered cfgsync outputs written for server startup. #[derive(Debug, Clone)] pub struct RenderedCfgsync { + /// Serialized cfgsync server config YAML. pub config_yaml: String, + /// Serialized node bundle YAML. pub bundle_yaml: String, } +/// Output paths used when materializing rendered cfgsync files. #[derive(Debug, Clone, Copy)] pub struct CfgsyncOutputPaths<'a> { pub config_path: &'a Path, pub bundle_path: &'a Path, } +/// Ensures bundle path override exists, defaulting to output bundle file name. pub fn ensure_bundle_path(bundle_path: &mut Option, output_bundle_path: &Path) { if bundle_path.is_some() { return; @@ -29,12 +35,14 @@ pub fn ensure_bundle_path(bundle_path: &mut Option, output_bundle_path: ); } +/// Applies a minimum timeout floor to an existing timeout value. pub fn apply_timeout_floor(timeout: &mut u64, min_timeout_secs: Option) { if let Some(min_timeout_secs) = min_timeout_secs { *timeout = (*timeout).max(min_timeout_secs); } } +/// Writes rendered cfgsync server and bundle YAML files. pub fn write_rendered_cfgsync( rendered: &RenderedCfgsync, output: CfgsyncOutputPaths<'_>, @@ -44,6 +52,7 @@ pub fn write_rendered_cfgsync( Ok(()) } +/// Optional overrides applied to a cfgsync template document. #[derive(Debug, Clone, Default)] pub struct CfgsyncConfigOverrides { pub port: Option, @@ -53,12 +62,20 @@ pub struct CfgsyncConfigOverrides { pub metrics_otlp_ingest_url: Option, } +#[derive(Debug, Error)] +enum RenderTemplateError { + #[error("cfgsync template key `{key}` must be a YAML map")] + NonMappingEntry { key: String }, +} + +/// Loads cfgsync template YAML from disk. pub fn load_cfgsync_template_yaml(path: &Path) -> Result { let file = fs::File::open(path) .with_context(|| format!("opening cfgsync template at {}", path.display()))?; serde_yaml::from_reader(file).context("parsing cfgsync template") } +/// Renders cfgsync config YAML by applying overrides to a template document. pub fn render_cfgsync_yaml_from_template( mut template: Value, overrides: &CfgsyncConfigOverrides, @@ -67,6 +84,7 @@ pub fn render_cfgsync_yaml_from_template( serde_yaml::to_string(&template).context("serializing rendered cfgsync config") } +/// Applies cfgsync-specific override fields to a mutable YAML document. pub fn apply_cfgsync_overrides( template: &mut Value, overrides: &CfgsyncConfigOverrides, @@ -105,7 +123,7 @@ pub fn apply_cfgsync_overrides( } if let Some(endpoint) = &overrides.metrics_otlp_ingest_url { - let tracing_settings = nested_mapping_mut(root, "tracing_settings"); + let tracing_settings = nested_mapping_mut(root, "tracing_settings")?; tracing_settings.insert( Value::String("metrics".to_string()), parse_otlp_metrics_layer(endpoint)?, @@ -121,19 +139,20 @@ fn mapping_mut(value: &mut Value) -> Result<&mut Mapping> { .context("cfgsync template root must be a YAML map") } -fn nested_mapping_mut<'a>(mapping: &'a mut Mapping, key: &str) -> &'a mut Mapping { - let key = Value::String(key.to_string()); +fn nested_mapping_mut<'a>(mapping: &'a mut Mapping, key: &str) -> Result<&'a mut Mapping> { + let key_name = key.to_owned(); + let key = Value::String(key_name.clone()); let entry = mapping .entry(key) .or_insert_with(|| Value::Mapping(Mapping::new())); if !entry.is_mapping() { - *entry = Value::Mapping(Mapping::new()); + return Err(RenderTemplateError::NonMappingEntry { key: key_name }).map_err(Into::into); } entry .as_mapping_mut() - .expect("mapping entry should always be a mapping") + .context("cfgsync template entry should be a YAML map") } fn parse_otlp_metrics_layer(endpoint: &str) -> Result { diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs new file mode 100644 index 0000000..560e265 --- /dev/null +++ b/cfgsync/core/src/repo.rs @@ -0,0 +1,233 @@ +use std::{collections::HashMap, fs, path::Path, sync::Arc}; + +use cfgsync_artifacts::ArtifactFile; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{CfgSyncBundle, CfgSyncBundleNode}; + +/// Schema version served by cfgsync payload responses. +pub const CFGSYNC_SCHEMA_VERSION: u16 = 1; + +/// Canonical cfgsync file type used in payloads and bundles. +pub type CfgSyncFile = ArtifactFile; + +/// Payload returned by cfgsync server for one node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfgSyncPayload { + /// Payload schema version for compatibility checks. + pub schema_version: u16, + /// Files that must be written on the target node. + #[serde(default)] + pub files: Vec, +} + +impl CfgSyncPayload { + #[must_use] + pub fn from_files(files: Vec) -> Self { + Self { + schema_version: CFGSYNC_SCHEMA_VERSION, + files, + } + } + + #[must_use] + pub fn files(&self) -> &[CfgSyncFile] { + &self.files + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CfgSyncErrorCode { + MissingConfig, + Internal, +} + +/// Structured error body returned by cfgsync server. +#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[error("{code:?}: {message}")] +pub struct CfgSyncErrorResponse { + pub code: CfgSyncErrorCode, + pub message: String, +} + +impl CfgSyncErrorResponse { + #[must_use] + pub fn missing_config(identifier: &str) -> Self { + Self { + code: CfgSyncErrorCode::MissingConfig, + message: format!("missing config for host {identifier}"), + } + } + + #[must_use] + pub fn internal(message: impl Into) -> Self { + Self { + code: CfgSyncErrorCode::Internal, + message: message.into(), + } + } +} + +/// Repository resolution outcome for a requested node identifier. +pub enum RepoResponse { + Config(CfgSyncPayload), + Error(CfgSyncErrorResponse), +} + +/// Read-only source for cfgsync node payloads. +pub trait ConfigProvider: Send + Sync { + fn resolve(&self, identifier: &str) -> RepoResponse; +} + +/// In-memory map-backed provider used by cfgsync server state. +pub struct ConfigRepo { + configs: HashMap, +} + +impl ConfigRepo { + #[must_use] + pub fn from_bundle(configs: HashMap) -> Arc { + Arc::new(Self { configs }) + } +} + +impl ConfigProvider for ConfigRepo { + fn resolve(&self, identifier: &str) -> RepoResponse { + self.configs.get(identifier).cloned().map_or_else( + || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), + RepoResponse::Config, + ) + } +} + +/// Failures when loading a file-backed cfgsync provider. +#[derive(Debug, Error)] +pub enum FileConfigProviderError { + #[error("failed to read cfgsync bundle at {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse cfgsync bundle at {path}: {source}")] + Parse { + path: String, + #[source] + source: serde_yaml::Error, + }, +} + +/// YAML bundle-backed provider implementation. +pub struct FileConfigProvider { + inner: ConfigRepo, +} + +impl FileConfigProvider { + /// Loads provider state from a cfgsync bundle YAML file. + pub fn from_yaml_file(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| FileConfigProviderError::Read { + path: path.display().to_string(), + source, + })?; + + let bundle: CfgSyncBundle = + serde_yaml::from_str(&raw).map_err(|source| FileConfigProviderError::Parse { + path: path.display().to_string(), + source, + })?; + + let configs = bundle + .nodes + .into_iter() + .map(payload_from_bundle_node) + .collect(); + + Ok(Self { + inner: ConfigRepo { configs }, + }) + } +} + +impl ConfigProvider for FileConfigProvider { + fn resolve(&self, identifier: &str) -> RepoResponse { + self.inner.resolve(identifier) + } +} + +fn payload_from_bundle_node(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { + (node.identifier, CfgSyncPayload::from_files(node.files)) +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use tempfile::NamedTempFile; + + use super::*; + + fn sample_payload() -> CfgSyncPayload { + CfgSyncPayload::from_files(vec![CfgSyncFile::new("/config.yaml", "key: value")]) + } + + #[test] + fn resolves_existing_identifier() { + let mut configs = HashMap::new(); + configs.insert("node-1".to_owned(), sample_payload()); + let repo = ConfigRepo { configs }; + + match repo.resolve("node-1") { + RepoResponse::Config(payload) => { + assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].path, "/config.yaml"); + } + RepoResponse::Error(error) => panic!("expected config response, got {error}"), + } + } + + #[test] + fn reports_missing_identifier() { + let repo = ConfigRepo { + configs: HashMap::new(), + }; + + match repo.resolve("unknown-node") { + RepoResponse::Config(_) => panic!("expected missing-config error"), + RepoResponse::Error(error) => { + assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); + assert!(error.message.contains("unknown-node")); + } + } + } + + #[test] + fn loads_file_provider_bundle() { + let mut bundle_file = NamedTempFile::new().expect("create temp bundle"); + let yaml = r#" +nodes: + - identifier: node-1 + files: + - path: /config.yaml + content: "a: 1" +"#; + bundle_file + .write_all(yaml.as_bytes()) + .expect("write bundle yaml"); + + let provider = + FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider"); + + match provider.resolve("node-1") { + RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), + RepoResponse::Error(error) => panic!("expected config, got {error}"), + } + } +} diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs new file mode 100644 index 0000000..e841610 --- /dev/null +++ b/cfgsync/core/src/server.rs @@ -0,0 +1,172 @@ +use std::{io, net::Ipv4Addr, sync::Arc}; + +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::repo::{CfgSyncErrorCode, ConfigProvider, RepoResponse}; + +/// Request payload used by cfgsync client for node config resolution. +#[derive(Serialize, Deserialize)] +pub struct ClientIp { + /// Node IP that can be used by clients for observability/logging. + pub ip: Ipv4Addr, + /// Stable node identifier used as key in cfgsync bundle lookup. + pub identifier: String, +} + +/// Runtime state shared across cfgsync HTTP handlers. +pub struct CfgSyncState { + repo: Arc, +} + +impl CfgSyncState { + #[must_use] + pub fn new(repo: Arc) -> 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>, + Json(payload): Json, +) -> impl IntoResponse { + let response = resolve_node_config_response(&state, &payload.identifier); + + 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() + } + } +} + +fn resolve_node_config_response(state: &CfgSyncState, identifier: &str) -> RepoResponse { + state.repo.resolve(identifier) +} + +fn error_status(code: &CfgSyncErrorCode) -> StatusCode { + match code { + CfgSyncErrorCode::MissingConfig => StatusCode::NOT_FOUND, + CfgSyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +pub fn cfgsync_app(state: CfgSyncState) -> Router { + Router::new() + .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}; + + use super::{CfgSyncState, ClientIp, node_config}; + use crate::repo::{ + CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, + CfgSyncPayload, ConfigProvider, RepoResponse, + }; + + struct StaticProvider { + data: HashMap, + } + + impl ConfigProvider for StaticProvider { + fn resolve(&self, identifier: &str) -> RepoResponse { + self.data.get(identifier).cloned().map_or_else( + || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), + RepoResponse::Config, + ) + } + } + + 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(StaticProvider { data }); + let state = Arc::new(CfgSyncState::new(provider)); + let payload = ClientIp { + 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::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)); + let payload = ClientIp { + 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)); + } +} diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml new file mode 100644 index 0000000..045bc73 --- /dev/null +++ b/cfgsync/runtime/Cargo.toml @@ -0,0 +1,26 @@ +[package] +categories = { workspace = true } +description = { workspace = true } +edition = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +name = "cfgsync-runtime" +readme = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +cfgsync-core = { workspace = true } +clap = { version = "4", features = ["derive"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/testing-framework/tools/cfgsync-runtime/src/bin/cfgsync-client.rs b/cfgsync/runtime/src/bin/cfgsync-client.rs similarity index 100% rename from testing-framework/tools/cfgsync-runtime/src/bin/cfgsync-client.rs rename to cfgsync/runtime/src/bin/cfgsync-client.rs diff --git a/testing-framework/tools/cfgsync-runtime/src/bin/cfgsync-server.rs b/cfgsync/runtime/src/bin/cfgsync-server.rs similarity index 100% rename from testing-framework/tools/cfgsync-runtime/src/bin/cfgsync-server.rs rename to cfgsync/runtime/src/bin/cfgsync-server.rs diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs new file mode 100644 index 0000000..aab3d1c --- /dev/null +++ b/cfgsync/runtime/src/client.rs @@ -0,0 +1,205 @@ +use std::{ + env, fs, + net::Ipv4Addr, + path::{Path, PathBuf}, +}; + +use anyhow::{Context as _, Result, bail}; +use cfgsync_core::{CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, ClientIp}; +use thiserror::Error; +use tokio::time::{Duration, sleep}; +use tracing::info; + +const FETCH_ATTEMPTS: usize = 5; +const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); + +#[derive(Debug, Error)] +enum ClientEnvError { + #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] + InvalidIp { value: String }, +} + +async fn fetch_with_retry(payload: &ClientIp, server_addr: &str) -> Result { + let client = CfgSyncClient::new(server_addr); + + for attempt in 1..=FETCH_ATTEMPTS { + match fetch_once(&client, payload).await { + Ok(config) => return Ok(config), + Err(error) => { + if attempt == FETCH_ATTEMPTS { + return Err(error).with_context(|| { + format!("fetching cfgsync payload after {attempt} attempts") + }); + } + + sleep(FETCH_RETRY_DELAY).await; + } + } + } + + unreachable!("cfgsync fetch loop always returns before exhausting attempts"); +} + +async fn fetch_once(client: &CfgSyncClient, payload: &ClientIp) -> Result { + let response = client.fetch_node_config(payload).await?; + + Ok(response) +} + +async fn pull_config_files(payload: ClientIp, server_addr: &str) -> Result<()> { + let config = fetch_with_retry(&payload, server_addr) + .await + .context("fetching cfgsync node config")?; + ensure_schema_version(&config)?; + + let files = collect_payload_files(&config)?; + + for file in files { + write_cfgsync_file(file)?; + } + + info!(files = files.len(), "cfgsync files saved"); + + Ok(()) +} + +fn ensure_schema_version(config: &CfgSyncPayload) -> Result<()> { + if config.schema_version != CFGSYNC_SCHEMA_VERSION { + bail!( + "unsupported cfgsync payload schema version {}, expected {}", + config.schema_version, + CFGSYNC_SCHEMA_VERSION + ); + } + + Ok(()) +} + +fn collect_payload_files(config: &CfgSyncPayload) -> Result<&[CfgSyncFile]> { + if config.is_empty() { + bail!("cfgsync payload contains no files"); + } + + Ok(config.files()) +} + +fn write_cfgsync_file(file: &CfgSyncFile) -> Result<()> { + let path = PathBuf::from(&file.path); + + ensure_parent_dir(&path)?; + + fs::write(&path, &file.content).with_context(|| format!("writing {}", path.display()))?; + + info!(path = %path.display(), "cfgsync file saved"); + + Ok(()) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + + if parent.as_os_str().is_empty() { + return Ok(()); + } + + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory {}", parent.display()))?; + + Ok(()) +} + +/// Resolves cfgsync client inputs from environment and materializes node files. +pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { + let server_addr = + env::var("CFG_SERVER_ADDR").unwrap_or_else(|_| format!("http://127.0.0.1:{default_port}")); + let ip = parse_ip_env(&env::var("CFG_HOST_IP").unwrap_or_else(|_| "127.0.0.1".to_owned()))?; + let identifier = + env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); + + pull_config_files(ClientIp { ip, identifier }, &server_addr).await +} + +fn parse_ip_env(ip_str: &str) -> Result { + ip_str + .parse() + .map_err(|_| ClientEnvError::InvalidIp { + value: ip_str.to_owned(), + }) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use cfgsync_core::{ + CfgSyncBundle, CfgSyncBundleNode, CfgSyncPayload, CfgSyncState, ConfigRepo, run_cfgsync, + }; + use tempfile::tempdir; + + use super::*; + + #[tokio::test] + async fn client_materializes_multi_file_payload_from_cfgsync_server() { + let dir = tempdir().expect("create temp dir"); + let app_config_path = dir.path().join("config.yaml"); + let deployment_path = dir.path().join("deployment.yaml"); + + let bundle = CfgSyncBundle::new(vec![CfgSyncBundleNode { + identifier: "node-1".to_owned(), + files: vec![ + CfgSyncFile::new(app_config_path.to_string_lossy(), "app_key: app_value"), + CfgSyncFile::new(deployment_path.to_string_lossy(), "mode: local"), + ], + }]); + + let repo = ConfigRepo::from_bundle(bundle_to_payload_map(bundle)); + let state = CfgSyncState::new(repo); + let port = allocate_test_port(); + let address = format!("http://127.0.0.1:{port}"); + let server = tokio::spawn(async move { + run_cfgsync(port, state).await.expect("run cfgsync server"); + }); + + pull_config_files( + ClientIp { + ip: "127.0.0.1".parse().expect("parse ip"), + identifier: "node-1".to_owned(), + }, + &address, + ) + .await + .expect("pull config files"); + + server.abort(); + let _ = server.await; + + let app_config = fs::read_to_string(&app_config_path).expect("read app config"); + let deployment = fs::read_to_string(&deployment_path).expect("read deployment config"); + + assert_eq!(app_config, "app_key: app_value"); + assert_eq!(deployment, "mode: local"); + } + + fn bundle_to_payload_map(bundle: CfgSyncBundle) -> HashMap { + bundle + .nodes + .into_iter() + .map(|node| { + let CfgSyncBundleNode { identifier, files } = node; + + (identifier, CfgSyncPayload::from_files(files)) + }) + .collect() + } + + fn allocate_test_port() -> u16 { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port for test"); + let port = listener.local_addr().expect("read local addr").port(); + drop(listener); + port + } +} diff --git a/testing-framework/tools/cfgsync-runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs similarity index 82% rename from testing-framework/tools/cfgsync-runtime/src/lib.rs rename to cfgsync/runtime/src/lib.rs index bc28a08..7c0a75b 100644 --- a/testing-framework/tools/cfgsync-runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -1,6 +1,3 @@ -pub mod bundle; -pub mod render; - pub use cfgsync_core as core; mod client; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs new file mode 100644 index 0000000..a038a43 --- /dev/null +++ b/cfgsync/runtime/src/server.rs @@ -0,0 +1,59 @@ +use std::{fs, path::Path, sync::Arc}; + +use anyhow::Context as _; +use cfgsync_core::{CfgSyncState, ConfigProvider, FileConfigProvider, run_cfgsync}; +use serde::Deserialize; + +/// Runtime cfgsync server config loaded from YAML. +#[derive(Debug, Deserialize, Clone)] +pub struct CfgSyncServerConfig { + pub port: u16, + pub bundle_path: String, +} + +impl CfgSyncServerConfig { + /// Loads cfgsync runtime server config from a YAML file. + pub fn load_from_file(path: &Path) -> anyhow::Result { + let config_content = fs::read_to_string(path) + .with_context(|| format!("failed to read cfgsync config file {}", path.display()))?; + + serde_yaml::from_str(&config_content) + .with_context(|| format!("failed to parse cfgsync config file {}", path.display())) + } +} + +fn load_bundle(bundle_path: &Path) -> anyhow::Result> { + let provider = FileConfigProvider::from_yaml_file(bundle_path) + .with_context(|| format!("loading cfgsync provider from {}", bundle_path.display()))?; + + Ok(Arc::new(provider)) +} + +fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { + let path = Path::new(bundle_path); + if path.is_absolute() { + return path.to_path_buf(); + } + + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) +} + +/// Loads runtime config and starts cfgsync HTTP server process. +pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { + let config = CfgSyncServerConfig::load_from_file(config_path)?; + let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); + + let state = build_server_state(&bundle_path)?; + run_cfgsync(config.port, state).await?; + + Ok(()) +} + +fn build_server_state(bundle_path: &Path) -> anyhow::Result { + let repo = load_bundle(bundle_path)?; + + Ok(CfgSyncState::new(repo)) +} diff --git a/logos/infra/assets/stack/scripts/docker/build_cfgsync.sh b/logos/infra/assets/stack/scripts/docker/build_cfgsync.sh index 61a4ad5..7921e8a 100755 --- a/logos/infra/assets/stack/scripts/docker/build_cfgsync.sh +++ b/logos/infra/assets/stack/scripts/docker/build_cfgsync.sh @@ -2,10 +2,10 @@ set -euo pipefail RUSTFLAGS='--cfg feature="pol-dev-mode"' \ - cargo build --manifest-path /workspace/testing-framework/tools/cfgsync-runtime/Cargo.toml --bin cfgsync-server + cargo build --manifest-path /workspace/cfgsync/runtime/Cargo.toml --bin cfgsync-server RUSTFLAGS='--cfg feature="pol-dev-mode"' \ - cargo build --manifest-path /workspace/testing-framework/tools/cfgsync-runtime/Cargo.toml --bin cfgsync-client + cargo build --manifest-path /workspace/cfgsync/runtime/Cargo.toml --bin cfgsync-client cp /workspace/target/debug/cfgsync-server /workspace/artifacts/cfgsync-server cp /workspace/target/debug/cfgsync-client /workspace/artifacts/cfgsync-client diff --git a/logos/runtime/ext/Cargo.toml b/logos/runtime/ext/Cargo.toml index 008bf4f..05dd243 100644 --- a/logos/runtime/ext/Cargo.toml +++ b/logos/runtime/ext/Cargo.toml @@ -7,8 +7,8 @@ version = { workspace = true } [dependencies] # Workspace crates +cfgsync-adapter = { workspace = true } cfgsync-core = { workspace = true } -cfgsync_runtime = { workspace = true } lb-framework = { workspace = true } testing-framework-core = { workspace = true } testing-framework-env = { workspace = true } diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 6d0b308..7fe4e44 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,7 +1,8 @@ -use anyhow::{Result, anyhow}; -pub(crate) use cfgsync_runtime::render::CfgsyncOutputPaths; -use cfgsync_runtime::{ - bundle::{CfgSyncBundle, CfgSyncBundleNode, build_cfgsync_bundle_with_hostnames}, +use anyhow::Result; +use cfgsync_adapter::{CfgsyncEnv, build_cfgsync_node_configs}; +pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; +use cfgsync_core::{ + CfgSyncBundle, CfgSyncBundleNode, render::{ CfgsyncConfigOverrides, RenderedCfgsync, ensure_bundle_path, render_cfgsync_yaml_from_template, write_rendered_cfgsync, @@ -9,7 +10,7 @@ use cfgsync_runtime::{ }; use reqwest::Url; use serde_yaml::{Mapping, Value}; -use testing_framework_core::cfgsync::CfgsyncEnv; +use thiserror::Error; pub(crate) struct CfgsyncRenderOptions { pub port: Option, @@ -18,6 +19,14 @@ pub(crate) struct CfgsyncRenderOptions { pub metrics_otlp_ingest_url: Option, } +#[derive(Debug, Error)] +enum BundleRenderError { + #[error("cfgsync bundle node `{identifier}` is missing `/config.yaml`")] + MissingConfigFile { identifier: String }, + #[error("cfgsync config file is missing `{key}`")] + MissingYamlKey { key: String }, +} + pub(crate) fn render_cfgsync_from_template( topology: &E::Deployment, hostnames: &[String], @@ -26,7 +35,7 @@ pub(crate) fn render_cfgsync_from_template( let cfg = build_cfgsync_server_config(); let overrides = build_overrides::(topology, options); let config_yaml = render_cfgsync_yaml_from_template(cfg, &overrides)?; - let mut bundle = build_cfgsync_bundle_with_hostnames::(topology, hostnames)?; + let mut bundle = build_cfgsync_bundle::(topology, hostnames)?; append_deployment_files(&mut bundle)?; let bundle_yaml = serde_yaml::to_string(&bundle)?; @@ -36,14 +45,32 @@ pub(crate) fn render_cfgsync_from_template( }) } +fn build_cfgsync_bundle( + topology: &E::Deployment, + hostnames: &[String], +) -> Result { + let nodes = build_cfgsync_node_configs::(topology, hostnames)?; + let nodes = nodes + .into_iter() + .map(|node| CfgSyncBundleNode { + identifier: node.identifier, + files: vec![build_bundle_file("/config.yaml", node.config_yaml)], + }) + .collect(); + + Ok(CfgSyncBundle::new(nodes)) +} + fn append_deployment_files(bundle: &mut CfgSyncBundle) -> Result<()> { for node in &mut bundle.nodes { if has_file_path(node, "/deployment.yaml") { continue; } - let config_content = config_file_content(node) - .ok_or_else(|| anyhow!("cfgsync bundle node missing /config.yaml"))?; + let config_content = + config_file_content(node).ok_or_else(|| BundleRenderError::MissingConfigFile { + identifier: node.identifier.clone(), + })?; let deployment_yaml = extract_yaml_key(&config_content, "deployment")?; node.files @@ -75,7 +102,9 @@ fn extract_yaml_key(content: &str, key: &str) -> Result { let value = document .get(key) .cloned() - .ok_or_else(|| anyhow!("config yaml missing `{key}`"))?; + .ok_or_else(|| BundleRenderError::MissingYamlKey { + key: key.to_owned(), + })?; Ok(serde_yaml::to_string(&value)?) } diff --git a/logos/runtime/ext/src/compose_env.rs b/logos/runtime/ext/src/compose_env.rs index 9803b12..4fa3018 100644 --- a/logos/runtime/ext/src/compose_env.rs +++ b/logos/runtime/ext/src/compose_env.rs @@ -254,7 +254,6 @@ fn build_compose_node_descriptor( base_volumes(), default_extra_hosts(), ports, - api_port, environment, platform, ) diff --git a/logos/runtime/ext/src/k8s_env.rs b/logos/runtime/ext/src/k8s_env.rs index 8c9a1e8..15b2dc9 100644 --- a/logos/runtime/ext/src/k8s_env.rs +++ b/logos/runtime/ext/src/k8s_env.rs @@ -31,7 +31,7 @@ use crate::{ const CFGSYNC_K8S_TIMEOUT_SECS: u64 = 300; const K8S_FULLNAME_OVERRIDE: &str = "logos-runner"; -const DEFAULT_K8S_TESTNET_IMAGE: &str = "logos-blockchain-testing:local"; +const DEFAULT_K8S_TESTNET_IMAGE: &str = "public.ecr.aws/r4s5t9y4/logos/logos-blockchain:test"; /// Paths and image metadata required to deploy the Helm chart. pub struct K8sAssets { diff --git a/testing-framework/core/Cargo.toml b/testing-framework/core/Cargo.toml index 00e0658..cda1d37 100644 --- a/testing-framework/core/Cargo.toml +++ b/testing-framework/core/Cargo.toml @@ -17,6 +17,7 @@ default = [] [dependencies] async-trait = "0.1" +cfgsync-adapter = { workspace = true } futures = { default-features = false, features = ["std"], version = "0.3" } parking_lot = { workspace = true } prometheus-http-query = "0.8" diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index 8f0ad34..af45d4d 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -1,84 +1 @@ -use std::error::Error; - -use thiserror::Error; - -pub type DynCfgsyncError = Box; - -#[derive(Debug, Clone)] -pub struct CfgsyncNodeConfig { - pub identifier: String, - pub config_yaml: String, -} - -pub trait CfgsyncEnv { - type Deployment; - type Node; - type NodeConfig; - type Error: Error + Send + Sync + 'static; - - fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; - - fn node_identifier(index: usize, node: &Self::Node) -> String; - - fn build_node_config( - deployment: &Self::Deployment, - node: &Self::Node, - ) -> Result; - - fn rewrite_for_hostnames( - deployment: &Self::Deployment, - node_index: usize, - hostnames: &[String], - config: &mut Self::NodeConfig, - ) -> Result<(), Self::Error>; - - fn serialize_node_config(config: &Self::NodeConfig) -> Result; -} - -#[derive(Debug, Error)] -pub enum BuildCfgsyncNodesError { - #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] - HostnameCountMismatch { nodes: usize, hostnames: usize }, - #[error("cfgsync adapter failed: {source}")] - Adapter { - #[source] - source: DynCfgsyncError, - }, -} - -fn adapter_error(source: E) -> BuildCfgsyncNodesError -where - E: Error + Send + Sync + 'static, -{ - BuildCfgsyncNodesError::Adapter { - source: Box::new(source), - } -} - -pub fn build_cfgsync_node_configs( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result, BuildCfgsyncNodesError> { - let nodes = E::nodes(deployment); - if nodes.len() != hostnames.len() { - return Err(BuildCfgsyncNodesError::HostnameCountMismatch { - nodes: nodes.len(), - hostnames: hostnames.len(), - }); - } - - let mut output = Vec::with_capacity(nodes.len()); - for (index, node) in nodes.iter().enumerate() { - let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; - E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) - .map_err(adapter_error)?; - let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; - - output.push(CfgsyncNodeConfig { - identifier: E::node_identifier(index, node), - config_yaml, - }); - } - - Ok(output) -} +pub use cfgsync_adapter::*; diff --git a/testing-framework/core/src/lib.rs b/testing-framework/core/src/lib.rs index 5cbdb97..3e76f81 100644 --- a/testing-framework/core/src/lib.rs +++ b/testing-framework/core/src/lib.rs @@ -1,3 +1,7 @@ +#[deprecated( + since = "0.1.0", + note = "testing-framework-core::cfgsync moved to cfgsync-adapter; update imports" +)] pub mod cfgsync; pub mod env; pub mod runtime; diff --git a/testing-framework/deployers/compose/Cargo.toml b/testing-framework/deployers/compose/Cargo.toml index 9ac8b7a..4df1f40 100644 --- a/testing-framework/deployers/compose/Cargo.toml +++ b/testing-framework/deployers/compose/Cargo.toml @@ -31,4 +31,5 @@ uuid = { features = ["v4"], version = "1" } [dev-dependencies] groth16 = { workspace = true } key-management-system-service = { workspace = true } +serde_json = { workspace = true } zksign = { workspace = true } diff --git a/testing-framework/deployers/compose/assets/docker-compose.yml.tera b/testing-framework/deployers/compose/assets/docker-compose.yml.tera index c6ecc29..ba21922 100644 --- a/testing-framework/deployers/compose/assets/docker-compose.yml.tera +++ b/testing-framework/deployers/compose/assets/docker-compose.yml.tera @@ -18,9 +18,6 @@ services: {% for port in node.ports %} - {{ port }} {% endfor %} - labels: - testing-framework.node: "true" - testing-framework.api-container-port: "{{ node.api_container_port }}" environment: {% for env in node.environment %} {{ env.key }}: "{{ env.value }}" diff --git a/testing-framework/deployers/compose/src/descriptor/node.rs b/testing-framework/deployers/compose/src/descriptor/node.rs index d35f8f5..c5f769b 100644 --- a/testing-framework/deployers/compose/src/descriptor/node.rs +++ b/testing-framework/deployers/compose/src/descriptor/node.rs @@ -9,7 +9,6 @@ pub struct NodeDescriptor { volumes: Vec, extra_hosts: Vec, ports: Vec, - api_container_port: u16, environment: Vec, #[serde(skip_serializing_if = "Option::is_none")] platform: Option, @@ -50,7 +49,6 @@ impl NodeDescriptor { volumes: Vec, extra_hosts: Vec, ports: Vec, - api_container_port: u16, environment: Vec, platform: Option, ) -> Self { @@ -61,7 +59,6 @@ impl NodeDescriptor { volumes, extra_hosts, ports, - api_container_port, environment, platform, } @@ -80,9 +77,4 @@ impl NodeDescriptor { pub fn environment(&self) -> &[EnvEntry] { &self.environment } - - #[cfg(test)] - pub fn api_container_port(&self) -> u16 { - self.api_container_port - } } diff --git a/testing-framework/deployers/k8s/src/env.rs b/testing-framework/deployers/k8s/src/env.rs index 4ec7fd2..353d7e9 100644 --- a/testing-framework/deployers/k8s/src/env.rs +++ b/testing-framework/deployers/k8s/src/env.rs @@ -106,7 +106,8 @@ pub trait K8sDeployEnv: Application { format!("{release}-node-{index}") } - /// Label selector used to discover managed node services in attached mode. + /// Label selector used to discover managed node services in + /// existing-cluster mode. fn attach_node_service_selector(release: &str) -> String { format!("app.kubernetes.io/instance={release}") } diff --git a/testing-framework/tools/cfgsync-core/Cargo.toml b/testing-framework/tools/cfgsync-core/Cargo.toml deleted file mode 100644 index 63f2d1f..0000000 --- a/testing-framework/tools/cfgsync-core/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -categories = { workspace = true } -description = { workspace = true } -edition = { workspace = true } -keywords = { workspace = true } -license = { workspace = true } -name = "cfgsync-core" -readme = { workspace = true } -repository = { workspace = true } -version = { workspace = true } - -[lints] -workspace = true - -[dependencies] -axum = { default-features = false, features = ["http1", "http2", "json", "tokio"], version = "0.7.5" } -reqwest = { features = ["json"], workspace = true } -serde = { default-features = false, features = ["derive"], version = "1" } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } diff --git a/testing-framework/tools/cfgsync-core/src/lib.rs b/testing-framework/tools/cfgsync-core/src/lib.rs deleted file mode 100644 index 822ae69..0000000 --- a/testing-framework/tools/cfgsync-core/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod client; -pub mod repo; -pub mod server; - -pub use client::{CfgSyncClient, ClientError}; -pub use repo::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, - ConfigRepo, RepoResponse, -}; -pub use server::{CfgSyncState, ClientIp, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/testing-framework/tools/cfgsync-core/src/repo.rs b/testing-framework/tools/cfgsync-core/src/repo.rs deleted file mode 100644 index 62e320c..0000000 --- a/testing-framework/tools/cfgsync-core/src/repo.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::oneshot::Sender; - -pub const CFGSYNC_SCHEMA_VERSION: u16 = 1; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncFile { - pub path: String, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncPayload { - pub schema_version: u16, - #[serde(default)] - pub files: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_yaml: Option, -} - -impl CfgSyncPayload { - #[must_use] - pub fn from_files(files: Vec) -> Self { - Self { - schema_version: CFGSYNC_SCHEMA_VERSION, - files, - config_yaml: None, - } - } - - #[must_use] - pub fn normalized_files(&self, default_config_path: &str) -> Vec { - if !self.files.is_empty() { - return self.files.clone(); - } - - self.config_yaml - .as_ref() - .map(|content| { - vec![CfgSyncFile { - path: default_config_path.to_owned(), - content: content.clone(), - }] - }) - .unwrap_or_default() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CfgSyncErrorCode { - MissingConfig, - Internal, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Error)] -#[error("{code:?}: {message}")] -pub struct CfgSyncErrorResponse { - pub code: CfgSyncErrorCode, - pub message: String, -} - -impl CfgSyncErrorResponse { - #[must_use] - pub fn missing_config(identifier: &str) -> Self { - Self { - code: CfgSyncErrorCode::MissingConfig, - message: format!("missing config for host {identifier}"), - } - } - - #[must_use] - pub fn internal(message: impl Into) -> Self { - Self { - code: CfgSyncErrorCode::Internal, - message: message.into(), - } - } -} - -pub enum RepoResponse { - Config(CfgSyncPayload), - Error(CfgSyncErrorResponse), -} - -pub struct ConfigRepo { - configs: HashMap, -} - -impl ConfigRepo { - #[must_use] - pub fn from_bundle(configs: HashMap) -> Arc { - Arc::new(Self { configs }) - } - - pub async fn register(&self, identifier: String, reply_tx: Sender) { - let response = self.configs.get(&identifier).cloned().map_or_else( - || RepoResponse::Error(CfgSyncErrorResponse::missing_config(&identifier)), - RepoResponse::Config, - ); - - let _ = reply_tx.send(response); - } -} diff --git a/testing-framework/tools/cfgsync-core/src/server.rs b/testing-framework/tools/cfgsync-core/src/server.rs deleted file mode 100644 index f519d53..0000000 --- a/testing-framework/tools/cfgsync-core/src/server.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::{io, net::Ipv4Addr, sync::Arc}; - -use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::oneshot::channel; - -use crate::repo::{CfgSyncErrorResponse, ConfigRepo, RepoResponse}; - -#[derive(Serialize, Deserialize)] -pub struct ClientIp { - /// Node IP that can be used by clients for observability/logging. - pub ip: Ipv4Addr, - /// Stable node identifier used as key in cfgsync bundle lookup. - pub identifier: String, -} - -pub struct CfgSyncState { - repo: Arc, -} - -impl CfgSyncState { - #[must_use] - pub fn new(repo: Arc) -> Self { - Self { repo } - } -} - -#[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>, - Json(payload): Json, -) -> impl IntoResponse { - let identifier = payload.identifier.clone(); - let (reply_tx, reply_rx) = channel(); - state.repo.register(identifier, reply_tx).await; - - match reply_rx.await { - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(CfgSyncErrorResponse::internal( - "error receiving config from repo", - )), - ) - .into_response(), - Ok(RepoResponse::Config(payload_data)) => { - (StatusCode::OK, Json(payload_data)).into_response() - } - - Ok(RepoResponse::Error(error)) => { - let status = match error.code { - crate::repo::CfgSyncErrorCode::MissingConfig => StatusCode::NOT_FOUND, - crate::repo::CfgSyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, - }; - (status, Json(error)).into_response() - } - } -} - -pub fn cfgsync_app(state: CfgSyncState) -> Router { - Router::new() - .route("/node", post(node_config)) - .route("/init-with-node", post(node_config)) - .with_state(Arc::new(state)) -} - -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(()) -} diff --git a/testing-framework/tools/cfgsync-runtime/Cargo.toml b/testing-framework/tools/cfgsync-runtime/Cargo.toml deleted file mode 100644 index f51d081..0000000 --- a/testing-framework/tools/cfgsync-runtime/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -categories = { workspace = true } -description = { workspace = true } -edition = { workspace = true } -keywords = { workspace = true } -license = { workspace = true } -name = "cfgsync-runtime" -readme = { workspace = true } -repository = { workspace = true } -version = { workspace = true } - -[lints] -workspace = true - -[dependencies] -anyhow = "1" -cfgsync-core = { workspace = true } -clap = { version = "4", features = ["derive"] } -serde = { workspace = true } -serde_yaml = { workspace = true } -testing-framework-core = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } diff --git a/testing-framework/tools/cfgsync-runtime/src/bundle.rs b/testing-framework/tools/cfgsync-runtime/src/bundle.rs deleted file mode 100644 index 8aa257d..0000000 --- a/testing-framework/tools/cfgsync-runtime/src/bundle.rs +++ /dev/null @@ -1,39 +0,0 @@ -use anyhow::Result; -use cfgsync_core::CfgSyncFile; -use serde::{Deserialize, Serialize}; -use testing_framework_core::cfgsync::{CfgsyncEnv, build_cfgsync_node_configs}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncBundle { - pub nodes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncBundleNode { - pub identifier: String, - #[serde(default)] - pub files: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_yaml: Option, -} - -pub fn build_cfgsync_bundle_with_hostnames( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result { - let nodes = build_cfgsync_node_configs::(deployment, hostnames)?; - - Ok(CfgSyncBundle { - nodes: nodes - .into_iter() - .map(|node| CfgSyncBundleNode { - identifier: node.identifier, - files: vec![CfgSyncFile { - path: "/config.yaml".to_owned(), - content: node.config_yaml, - }], - config_yaml: None, - }) - .collect(), - }) -} diff --git a/testing-framework/tools/cfgsync-runtime/src/client.rs b/testing-framework/tools/cfgsync-runtime/src/client.rs deleted file mode 100644 index 364fdf6..0000000 --- a/testing-framework/tools/cfgsync-runtime/src/client.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::{ - env, fs, - net::Ipv4Addr, - path::{Path, PathBuf}, -}; - -use anyhow::{Context as _, Result, anyhow, bail}; -use cfgsync_core::{CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, ClientIp}; -use tokio::time::{Duration, sleep}; - -const FETCH_ATTEMPTS: usize = 5; -const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); - -fn parse_ip(ip_str: &str) -> Ipv4Addr { - ip_str.parse().unwrap_or(Ipv4Addr::LOCALHOST) -} - -async fn fetch_with_retry(payload: &ClientIp, server_addr: &str) -> Result { - let client = CfgSyncClient::new(server_addr); - let mut last_error: Option = None; - - for attempt in 1..=FETCH_ATTEMPTS { - match client.fetch_node_config(payload).await { - Ok(config) => return Ok(config), - Err(error) => { - last_error = Some(error.into()); - - if attempt < FETCH_ATTEMPTS { - sleep(FETCH_RETRY_DELAY).await; - } - } - } - } - - match last_error { - Some(error) => Err(error), - None => Err(anyhow!("cfgsync client fetch failed without an error")), - } -} - -async fn pull_config_files(payload: ClientIp, server_addr: &str, config_file: &str) -> Result<()> { - let config = fetch_with_retry(&payload, server_addr) - .await - .context("fetching cfgsync node config")?; - ensure_schema_version(&config)?; - - let files = collect_payload_files(&config, config_file)?; - - for file in files { - write_cfgsync_file(&file)?; - } - - println!("Config files saved"); - Ok(()) -} - -fn ensure_schema_version(config: &CfgSyncPayload) -> Result<()> { - if config.schema_version != CFGSYNC_SCHEMA_VERSION { - bail!( - "unsupported cfgsync payload schema version {}, expected {}", - config.schema_version, - CFGSYNC_SCHEMA_VERSION - ); - } - - Ok(()) -} - -fn collect_payload_files(config: &CfgSyncPayload, config_file: &str) -> Result> { - let files = config.normalized_files(config_file); - if files.is_empty() { - bail!("cfgsync payload contains no files"); - } - - Ok(files) -} - -fn write_cfgsync_file(file: &CfgSyncFile) -> Result<()> { - let path = PathBuf::from(&file.path); - - ensure_parent_dir(&path)?; - - fs::write(&path, &file.content).with_context(|| format!("writing {}", path.display()))?; - - println!("Config saved to {}", path.display()); - Ok(()) -} - -fn ensure_parent_dir(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {}", parent.display()))?; - } - } - Ok(()) -} - -pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { - let config_file_path = env::var("CFG_FILE_PATH").unwrap_or_else(|_| "config.yaml".to_owned()); - let server_addr = - env::var("CFG_SERVER_ADDR").unwrap_or_else(|_| format!("http://127.0.0.1:{default_port}")); - let ip = parse_ip(&env::var("CFG_HOST_IP").unwrap_or_else(|_| "127.0.0.1".to_owned())); - let identifier = - env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); - - pull_config_files(ClientIp { ip, identifier }, &server_addr, &config_file_path).await -} diff --git a/testing-framework/tools/cfgsync-runtime/src/server.rs b/testing-framework/tools/cfgsync-runtime/src/server.rs deleted file mode 100644 index 4c3e59d..0000000 --- a/testing-framework/tools/cfgsync-runtime/src/server.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::{collections::HashMap, fs, path::Path, sync::Arc}; - -use anyhow::Context as _; -use cfgsync_core::{CfgSyncFile, CfgSyncPayload, CfgSyncState, ConfigRepo, run_cfgsync}; -use serde::Deserialize; - -#[derive(Debug, Deserialize, Clone)] -pub struct CfgSyncServerConfig { - pub port: u16, - pub bundle_path: String, -} - -impl CfgSyncServerConfig { - pub fn load_from_file(path: &Path) -> anyhow::Result { - let config_content = fs::read_to_string(path) - .with_context(|| format!("failed to read cfgsync config file {}", path.display()))?; - serde_yaml::from_str(&config_content) - .with_context(|| format!("failed to parse cfgsync config file {}", path.display())) - } -} - -#[derive(Debug, Deserialize)] -struct CfgSyncBundle { - nodes: Vec, -} - -#[derive(Debug, Deserialize)] -struct CfgSyncBundleNode { - identifier: String, - #[serde(default)] - files: Vec, - #[serde(default)] - config_yaml: Option, -} - -fn load_bundle(bundle_path: &Path) -> anyhow::Result> { - let bundle = read_cfgsync_bundle(bundle_path)?; - - let configs = bundle - .nodes - .into_iter() - .map(build_repo_entry) - .collect::>(); - - Ok(ConfigRepo::from_bundle(configs)) -} - -fn read_cfgsync_bundle(bundle_path: &Path) -> anyhow::Result { - let bundle_content = fs::read_to_string(bundle_path).with_context(|| { - format!( - "failed to read cfgsync bundle file {}", - bundle_path.display() - ) - })?; - - serde_yaml::from_str(&bundle_content) - .with_context(|| format!("failed to parse cfgsync bundle {}", bundle_path.display())) -} - -fn build_repo_entry(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { - let files = if node.files.is_empty() { - build_legacy_files(node.config_yaml) - } else { - node.files - }; - - (node.identifier, CfgSyncPayload::from_files(files)) -} - -fn build_legacy_files(config_yaml: Option) -> Vec { - config_yaml - .map(|content| { - vec![CfgSyncFile { - path: "/config.yaml".to_owned(), - content, - }] - }) - .unwrap_or_default() -} - -fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { - let path = Path::new(bundle_path); - if path.is_absolute() { - return path.to_path_buf(); - } - - config_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(path) -} - -pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { - let config = CfgSyncServerConfig::load_from_file(config_path)?; - let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); - - let repo = load_bundle(&bundle_path)?; - let state = CfgSyncState::new(repo); - run_cfgsync(config.port, state).await?; - Ok(()) -} From 129099337f33891e48e99b17898aac1d933ea748 Mon Sep 17 00:00:00 2001 From: andrussal Date: Mon, 9 Mar 2026 10:18:36 +0100 Subject: [PATCH 02/38] Add cfgsync registration flow --- cfgsync/adapter/src/lib.rs | 67 +++++++++++++++- cfgsync/core/src/client.rs | 64 ++++++++++++++-- cfgsync/core/src/lib.rs | 7 +- cfgsync/core/src/repo.rs | 110 ++++++++++++++++++++++++++- cfgsync/core/src/server.rs | 77 ++++++++++++++----- cfgsync/runtime/src/client.rs | 40 ++++++++-- logos/runtime/ext/src/cfgsync/mod.rs | 4 +- 7 files changed, 328 insertions(+), 41 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 467630d..0e0c7fa 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{collections::HashMap, error::Error}; use thiserror::Error; @@ -14,6 +14,44 @@ pub struct CfgsyncNodeConfig { pub config_yaml: String, } +/// Precomputed node configs indexed by stable identifier. +#[derive(Debug, Clone, Default)] +pub struct CfgsyncNodeCatalog { + nodes: HashMap, +} + +impl CfgsyncNodeCatalog { + #[must_use] + pub fn new(nodes: Vec) -> Self { + let nodes = nodes + .into_iter() + .map(|node| (node.identifier.clone(), node)) + .collect(); + + Self { nodes } + } + + #[must_use] + pub fn resolve(&self, identifier: &str) -> Option<&CfgsyncNodeConfig> { + self.nodes.get(identifier) + } + + #[must_use] + pub fn len(&self) -> usize { + self.nodes.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + #[must_use] + pub fn into_configs(self) -> Vec { + self.nodes.into_values().collect() + } +} + /// Adapter contract for converting an application deployment model into /// node-specific serialized config payloads. pub trait CfgsyncEnv { @@ -71,6 +109,14 @@ pub fn build_cfgsync_node_configs( deployment: &E::Deployment, hostnames: &[String], ) -> Result, BuildCfgsyncNodesError> { + Ok(build_cfgsync_node_catalog::(deployment, hostnames)?.into_configs()) +} + +/// Builds cfgsync node configs and indexes them by stable identifier. +pub fn build_cfgsync_node_catalog( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result { let nodes = E::nodes(deployment); ensure_hostname_count(nodes.len(), hostnames.len())?; @@ -79,7 +125,7 @@ pub fn build_cfgsync_node_configs( output.push(build_node_entry::(deployment, node, index, hostnames)?); } - Ok(output) + Ok(CfgsyncNodeCatalog::new(output)) } fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { @@ -117,3 +163,20 @@ fn build_rewritten_node_config( Ok(node_config) } + +#[cfg(test)] +mod tests { + use super::{CfgsyncNodeCatalog, CfgsyncNodeConfig}; + + #[test] + fn catalog_resolves_identifier() { + let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + identifier: "node-1".to_owned(), + config_yaml: "key: value".to_owned(), + }]); + + let node = catalog.resolve("node-1").expect("resolve node config"); + + assert_eq!(node.config_yaml, "key: value"); + } +} diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index 28e59b5..bfc07ec 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -1,10 +1,7 @@ use serde::Serialize; use thiserror::Error; -use crate::{ - repo::{CfgSyncErrorResponse, CfgSyncPayload}, - server::ClientIp, -}; +use crate::repo::{CfgSyncErrorResponse, CfgSyncPayload, NodeRegistration}; /// cfgsync client-side request/response failures. #[derive(Debug, Error)] @@ -21,6 +18,12 @@ pub enum ClientError { Decode(serde_json::Error), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFetchStatus { + Ready, + NotReady, +} + /// Reusable HTTP client for cfgsync server endpoints. #[derive(Clone, Debug)] pub struct CfgSyncClient { @@ -46,10 +49,15 @@ impl CfgSyncClient { &self.base_url } + /// Registers a node before requesting config. + pub async fn register_node(&self, payload: &NodeRegistration) -> Result<(), ClientError> { + self.post_status_only("/register", payload).await + } + /// Fetches `/node` payload for a node identifier. pub async fn fetch_node_config( &self, - payload: &ClientIp, + payload: &NodeRegistration, ) -> Result { self.post_json("/node", payload).await } @@ -57,11 +65,29 @@ impl CfgSyncClient { /// Fetches `/init-with-node` payload for a node identifier. pub async fn fetch_init_with_node_config( &self, - payload: &ClientIp, + payload: &NodeRegistration, ) -> Result { self.post_json("/init-with-node", payload).await } + pub async fn fetch_node_config_status( + &self, + payload: &NodeRegistration, + ) -> Result { + match self.fetch_node_config(payload).await { + Ok(_) => Ok(ConfigFetchStatus::Ready), + Err(ClientError::Status { + status, + error: Some(error), + .. + }) if status == reqwest::StatusCode::TOO_EARLY => { + let _ = error; + Ok(ConfigFetchStatus::NotReady) + } + Err(error) => Err(error), + } + } + /// Posts JSON payload to a cfgsync endpoint and decodes cfgsync payload. pub async fn post_json( &self, @@ -89,6 +115,32 @@ impl CfgSyncClient { serde_json::from_str(&body).map_err(ClientError::Decode) } + async fn post_status_only( + &self, + path: &str, + payload: &P, + ) -> Result<(), ClientError> { + let url = self.endpoint_url(path); + let response = self.http.post(url).json(payload).send().await?; + + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + let error = serde_json::from_str::(&body).ok(); + let message = error + .as_ref() + .map(|err| err.message.clone()) + .unwrap_or_else(|| body.clone()); + return Err(ClientError::Status { + status, + message, + error, + }); + } + + Ok(()) + } + fn endpoint_url(&self, path: &str) -> String { if path.starts_with('/') { format!("{}{}", self.base_url, path) diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index b6851e3..2807346 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -5,7 +5,7 @@ pub mod repo; pub mod server; pub use bundle::{CfgSyncBundle, CfgSyncBundleNode}; -pub use client::{CfgSyncClient, ClientError}; +pub use client::{CfgSyncClient, ClientError, ConfigFetchStatus}; pub use render::{ CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, apply_timeout_floor, ensure_bundle_path, load_cfgsync_template_yaml, @@ -13,6 +13,7 @@ pub use render::{ }; pub use repo::{ CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, - ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, RepoResponse, + ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration, + RegistrationResponse, RepoResponse, }; -pub use server::{CfgSyncState, ClientIp, RunCfgsyncError, cfgsync_app, run_cfgsync}; +pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index 560e265..dbce99f 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, fs, path::Path, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + fs, + net::Ipv4Addr, + path::Path, + sync::{Arc, Mutex}, +}; use cfgsync_artifacts::ArtifactFile; use serde::{Deserialize, Serialize}; @@ -22,6 +28,13 @@ pub struct CfgSyncPayload { pub files: Vec, } +/// Node metadata recorded before config materialization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeRegistration { + pub identifier: String, + pub ip: Ipv4Addr, +} + impl CfgSyncPayload { #[must_use] pub fn from_files(files: Vec) -> Self { @@ -46,6 +59,7 @@ impl CfgSyncPayload { #[serde(rename_all = "snake_case")] pub enum CfgSyncErrorCode { MissingConfig, + NotReady, Internal, } @@ -66,6 +80,14 @@ impl CfgSyncErrorResponse { } } + #[must_use] + pub fn not_ready(identifier: &str) -> Self { + Self { + code: CfgSyncErrorCode::NotReady, + message: format!("config for host {identifier} is not ready"), + } + } + #[must_use] pub fn internal(message: impl Into) -> Self { Self { @@ -81,25 +103,72 @@ pub enum RepoResponse { Error(CfgSyncErrorResponse), } +/// Repository outcome for a node registration request. +pub enum RegistrationResponse { + Registered, + Error(CfgSyncErrorResponse), +} + /// Read-only source for cfgsync node payloads. pub trait ConfigProvider: Send + Sync { + fn register(&self, registration: NodeRegistration) -> RegistrationResponse; + fn resolve(&self, identifier: &str) -> RepoResponse; } /// In-memory map-backed provider used by cfgsync server state. pub struct ConfigRepo { configs: HashMap, + registrations: Mutex>, } impl ConfigRepo { #[must_use] pub fn from_bundle(configs: HashMap) -> Arc { - Arc::new(Self { configs }) + Arc::new(Self { + configs, + registrations: Mutex::new(HashSet::new()), + }) + } + + fn register_identifier(&self, identifier: &str) -> RegistrationResponse { + if !self.configs.contains_key(identifier) { + return RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(identifier)); + } + + let mut registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + registrations.insert(identifier.to_owned()); + + RegistrationResponse::Registered + } + + fn is_registered(&self, identifier: &str) -> bool { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + registrations.contains(identifier) } } impl ConfigProvider for ConfigRepo { + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + self.register_identifier(®istration.identifier) + } + fn resolve(&self, identifier: &str) -> RepoResponse { + if !self.configs.contains_key(identifier) { + return RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)); + } + + if !self.is_registered(identifier) { + return RepoResponse::Error(CfgSyncErrorResponse::not_ready(identifier)); + } + self.configs.get(identifier).cloned().map_or_else( || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), RepoResponse::Config, @@ -150,12 +219,19 @@ impl FileConfigProvider { .collect(); Ok(Self { - inner: ConfigRepo { configs }, + inner: ConfigRepo { + configs, + registrations: Mutex::new(HashSet::new()), + }, }) } } impl ConfigProvider for FileConfigProvider { + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + self.inner.register(registration) + } + fn resolve(&self, identifier: &str) -> RepoResponse { self.inner.resolve(identifier) } @@ -181,7 +257,10 @@ mod tests { fn resolves_existing_identifier() { let mut configs = HashMap::new(); configs.insert("node-1".to_owned(), sample_payload()); - let repo = ConfigRepo { configs }; + let repo = ConfigRepo { + configs, + registrations: Mutex::new(HashSet::from(["node-1".to_owned()])), + }; match repo.resolve("node-1") { RepoResponse::Config(payload) => { @@ -197,6 +276,7 @@ mod tests { fn reports_missing_identifier() { let repo = ConfigRepo { configs: HashMap::new(), + registrations: Mutex::new(HashSet::new()), }; match repo.resolve("unknown-node") { @@ -225,9 +305,31 @@ nodes: let provider = FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider"); + let _ = provider.register(NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }); + match provider.resolve("node-1") { RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), RepoResponse::Error(error) => panic!("expected config, got {error}"), } } + + #[test] + fn resolve_requires_registration_first() { + let mut configs = HashMap::new(); + configs.insert("node-1".to_owned(), sample_payload()); + let repo = ConfigRepo { + configs, + registrations: Mutex::new(HashSet::new()), + }; + + match repo.resolve("node-1") { + RepoResponse::Config(_) => panic!("expected not-ready error"), + RepoResponse::Error(error) => { + assert!(matches!(error.code, CfgSyncErrorCode::NotReady)); + } + } + } } diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index e841610..330c80d 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -1,19 +1,11 @@ -use std::{io, net::Ipv4Addr, sync::Arc}; +use std::{io, sync::Arc}; use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; -use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::repo::{CfgSyncErrorCode, ConfigProvider, RepoResponse}; - -/// Request payload used by cfgsync client for node config resolution. -#[derive(Serialize, Deserialize)] -pub struct ClientIp { - /// Node IP that can be used by clients for observability/logging. - pub ip: Ipv4Addr, - /// Stable node identifier used as key in cfgsync bundle lookup. - pub identifier: String, -} +use crate::repo::{ + CfgSyncErrorCode, ConfigProvider, NodeRegistration, RegistrationResponse, RepoResponse, +}; /// Runtime state shared across cfgsync HTTP handlers. pub struct CfgSyncState { @@ -45,7 +37,7 @@ pub enum RunCfgsyncError { async fn node_config( State(state): State>, - Json(payload): Json, + Json(payload): Json, ) -> impl IntoResponse { let response = resolve_node_config_response(&state, &payload.identifier); @@ -59,6 +51,20 @@ async fn node_config( } } +async fn register_node( + State(state): State>, + Json(payload): Json, +) -> 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, identifier: &str) -> RepoResponse { state.repo.resolve(identifier) } @@ -66,12 +72,14 @@ fn resolve_node_config_response(state: &CfgSyncState, identifier: &str) -> RepoR fn error_status(code: &CfgSyncErrorCode) -> StatusCode { match code { CfgSyncErrorCode::MissingConfig => StatusCode::NOT_FOUND, + CfgSyncErrorCode::NotReady => StatusCode::TOO_EARLY, CfgSyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, } } pub fn cfgsync_app(state: CfgSyncState) -> Router { Router::new() + .route("/register", post(register_node)) .route("/node", post(node_config)) .route("/init-with-node", post(node_config)) .with_state(Arc::new(state)) @@ -100,10 +108,10 @@ mod tests { use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; - use super::{CfgSyncState, ClientIp, node_config}; + use super::{CfgSyncState, NodeRegistration, node_config, register_node}; use crate::repo::{ CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, - CfgSyncPayload, ConfigProvider, RepoResponse, + CfgSyncPayload, ConfigProvider, RegistrationResponse, RepoResponse, }; struct StaticProvider { @@ -111,6 +119,16 @@ mod tests { } impl ConfigProvider for StaticProvider { + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + if self.data.contains_key(®istration.identifier) { + RegistrationResponse::Registered + } else { + RegistrationResponse::Error(CfgSyncErrorResponse::missing_config( + ®istration.identifier, + )) + } + } + fn resolve(&self, identifier: &str) -> RepoResponse { self.data.get(identifier).cloned().map_or_else( || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), @@ -131,13 +149,17 @@ mod tests { let mut data = HashMap::new(); data.insert("node-a".to_owned(), sample_payload()); - let provider = Arc::new(StaticProvider { data }); + let provider = crate::repo::ConfigRepo::from_bundle(data); let state = Arc::new(CfgSyncState::new(provider)); - let payload = ClientIp { + let payload = NodeRegistration { ip: "127.0.0.1".parse().expect("valid ip"), identifier: "node-a".to_owned(), }; + let _ = register_node(State(state.clone()), Json(payload.clone())) + .await + .into_response(); + let response = node_config(State(state), Json(payload)) .await .into_response(); @@ -151,7 +173,7 @@ mod tests { data: HashMap::new(), }); let state = Arc::new(CfgSyncState::new(provider)); - let payload = ClientIp { + let payload = NodeRegistration { ip: "127.0.0.1".parse().expect("valid ip"), identifier: "missing-node".to_owned(), }; @@ -169,4 +191,23 @@ mod tests { assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); } + + #[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 = crate::repo::ConfigRepo::from_bundle(data); + 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); + } } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index aab3d1c..82dbe65 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -5,7 +5,9 @@ use std::{ }; use anyhow::{Context as _, Result, bail}; -use cfgsync_core::{CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, ClientIp}; +use cfgsync_core::{ + CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration, +}; use thiserror::Error; use tokio::time::{Duration, sleep}; use tracing::info; @@ -19,7 +21,7 @@ enum ClientEnvError { InvalidIp { value: String }, } -async fn fetch_with_retry(payload: &ClientIp, server_addr: &str) -> Result { +async fn fetch_with_retry(payload: &NodeRegistration, server_addr: &str) -> Result { let client = CfgSyncClient::new(server_addr); for attempt in 1..=FETCH_ATTEMPTS { @@ -40,13 +42,15 @@ async fn fetch_with_retry(payload: &ClientIp, server_addr: &str) -> Result Result { +async fn fetch_once(client: &CfgSyncClient, payload: &NodeRegistration) -> Result { let response = client.fetch_node_config(payload).await?; Ok(response) } -async fn pull_config_files(payload: ClientIp, server_addr: &str) -> Result<()> { +async fn pull_config_files(payload: NodeRegistration, server_addr: &str) -> Result<()> { + register_node(&payload, server_addr).await?; + let config = fetch_with_retry(&payload, server_addr) .await .context("fetching cfgsync node config")?; @@ -63,6 +67,30 @@ async fn pull_config_files(payload: ClientIp, server_addr: &str) -> Result<()> { Ok(()) } +async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result<()> { + let client = CfgSyncClient::new(server_addr); + + for attempt in 1..=FETCH_ATTEMPTS { + match client.register_node(payload).await { + Ok(()) => { + info!(identifier = %payload.identifier, "cfgsync node registered"); + return Ok(()); + } + Err(error) => { + if attempt == FETCH_ATTEMPTS { + return Err(error).with_context(|| { + format!("registering node with cfgsync after {attempt} attempts") + }); + } + + sleep(FETCH_RETRY_DELAY).await; + } + } + } + + unreachable!("cfgsync register loop always returns before exhausting attempts"); +} + fn ensure_schema_version(config: &CfgSyncPayload) -> Result<()> { if config.schema_version != CFGSYNC_SCHEMA_VERSION { bail!( @@ -118,7 +146,7 @@ pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { let identifier = env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); - pull_config_files(ClientIp { ip, identifier }, &server_addr).await + pull_config_files(NodeRegistration { ip, identifier }, &server_addr).await } fn parse_ip_env(ip_str: &str) -> Result { @@ -164,7 +192,7 @@ mod tests { }); pull_config_files( - ClientIp { + NodeRegistration { ip: "127.0.0.1".parse().expect("parse ip"), identifier: "node-1".to_owned(), }, diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 7fe4e44..074223d 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use cfgsync_adapter::{CfgsyncEnv, build_cfgsync_node_configs}; +use cfgsync_adapter::{CfgsyncEnv, build_cfgsync_node_catalog}; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::{ CfgSyncBundle, CfgSyncBundleNode, @@ -49,7 +49,7 @@ fn build_cfgsync_bundle( topology: &E::Deployment, hostnames: &[String], ) -> Result { - let nodes = build_cfgsync_node_configs::(topology, hostnames)?; + let nodes = build_cfgsync_node_catalog::(topology, hostnames)?.into_configs(); let nodes = nodes .into_iter() .map(|node| CfgSyncBundleNode { From 911d09e2c1c7f5e7d86f47fe391ebecd1e514ddc Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 08:57:41 +0100 Subject: [PATCH 03/38] Add adapter-backed cfgsync materialization --- Cargo.lock | 8 +- cfgsync/adapter/Cargo.toml | 4 +- cfgsync/adapter/src/lib.rs | 161 ++++++++++++++++++++++++++++++++++++- cfgsync/core/src/repo.rs | 120 +++++++++++---------------- cfgsync/core/src/server.rs | 84 ++++++++++++++++--- 5 files changed, 287 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d00c93..37f4acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,8 @@ dependencies = [ name = "cfgsync-adapter" version = "0.1.0" dependencies = [ + "cfgsync-artifacts", + "cfgsync-core", "thiserror 2.0.18", ] @@ -1320,7 +1322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.114", + "syn 1.0.109", ] [[package]] @@ -5520,9 +5522,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", diff --git a/cfgsync/adapter/Cargo.toml b/cfgsync/adapter/Cargo.toml index 034b349..7a15ad1 100644 --- a/cfgsync/adapter/Cargo.toml +++ b/cfgsync/adapter/Cargo.toml @@ -13,4 +13,6 @@ version = { workspace = true } workspace = true [dependencies] -thiserror = { workspace = true } +cfgsync-artifacts = { workspace = true } +cfgsync-core = { workspace = true } +thiserror = { workspace = true } diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 0e0c7fa..ff35b4e 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -1,5 +1,10 @@ -use std::{collections::HashMap, error::Error}; +use std::{collections::HashMap, error::Error, sync::Mutex}; +use cfgsync_artifacts::ArtifactFile; +use cfgsync_core::{ + CfgSyncErrorResponse, CfgSyncPayload, ConfigProvider, NodeRegistration, RegistrationResponse, + RepoResponse, +}; use thiserror::Error; /// Type-erased cfgsync adapter error used to preserve source context. @@ -14,6 +19,29 @@ pub struct CfgsyncNodeConfig { pub config_yaml: String, } +/// Node artifacts produced by a cfgsync materializer. +#[derive(Debug, Clone, Default)] +pub struct CfgsyncNodeArtifacts { + files: Vec, +} + +impl CfgsyncNodeArtifacts { + #[must_use] + pub fn new(files: Vec) -> Self { + Self { files } + } + + #[must_use] + pub fn files(&self) -> &[ArtifactFile] { + &self.files + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + /// Precomputed node configs indexed by stable identifier. #[derive(Debug, Clone, Default)] pub struct CfgsyncNodeCatalog { @@ -52,6 +80,91 @@ impl CfgsyncNodeCatalog { } } +/// Adapter-side node config materialization contract used by cfgsync server. +pub trait CfgsyncMaterializer: Send + Sync { + fn materialize( + &self, + registration: &NodeRegistration, + ) -> Result, DynCfgsyncError>; +} + +impl CfgsyncMaterializer for CfgsyncNodeCatalog { + fn materialize( + &self, + registration: &NodeRegistration, + ) -> Result, DynCfgsyncError> { + let artifacts = self + .resolve(®istration.identifier) + .map(build_node_artifacts_from_config); + + Ok(artifacts) + } +} + +/// Registration-aware provider backed by an adapter materializer. +pub struct MaterializingConfigProvider { + materializer: M, + registrations: Mutex>, +} + +impl MaterializingConfigProvider { + #[must_use] + pub fn new(materializer: M) -> Self { + Self { + materializer, + registrations: Mutex::new(HashMap::new()), + } + } + + fn registration_for(&self, identifier: &str) -> Option { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + registrations.get(identifier).cloned() + } +} + +impl ConfigProvider for MaterializingConfigProvider +where + M: CfgsyncMaterializer, +{ + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + let mut registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + registrations.insert(registration.identifier.clone(), registration); + + RegistrationResponse::Registered + } + + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + let registration = match self.registration_for(®istration.identifier) { + Some(registration) => registration, + None => { + return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + }; + + match self.materializer.materialize(®istration) { + Ok(Some(artifacts)) => { + RepoResponse::Config(CfgSyncPayload::from_files(artifacts.files().to_vec())) + } + Ok(None) => { + RepoResponse::Error(CfgSyncErrorResponse::not_ready(®istration.identifier)) + } + Err(error) => RepoResponse::Error(CfgSyncErrorResponse::internal(format!( + "failed to materialize config for host {}: {error}", + registration.identifier + ))), + } + } +} + /// Adapter contract for converting an application deployment model into /// node-specific serialized config payloads. pub trait CfgsyncEnv { @@ -164,9 +277,15 @@ fn build_rewritten_node_config( Ok(node_config) } +fn build_node_artifacts_from_config(config: &CfgsyncNodeConfig) -> CfgsyncNodeArtifacts { + CfgsyncNodeArtifacts::new(vec![ArtifactFile::new("/config.yaml", &config.config_yaml)]) +} + #[cfg(test)] mod tests { - use super::{CfgsyncNodeCatalog, CfgsyncNodeConfig}; + use cfgsync_core::{CfgSyncErrorCode, ConfigProvider, NodeRegistration, RepoResponse}; + + use super::{CfgsyncNodeCatalog, CfgsyncNodeConfig, MaterializingConfigProvider}; #[test] fn catalog_resolves_identifier() { @@ -179,4 +298,42 @@ mod tests { assert_eq!(node.config_yaml, "key: value"); } + + #[test] + fn materializing_provider_resolves_registered_node() { + let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + identifier: "node-1".to_owned(), + config_yaml: "key: value".to_owned(), + }]); + let provider = MaterializingConfigProvider::new(catalog); + let registration = NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }; + + let _ = provider.register(registration.clone()); + + match provider.resolve(®istration) { + RepoResponse::Config(payload) => assert_eq!(payload.files()[0].path, "/config.yaml"), + RepoResponse::Error(error) => panic!("expected config, got {error}"), + } + } + + #[test] + fn materializing_provider_reports_not_ready_before_registration() { + let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + identifier: "node-1".to_owned(), + config_yaml: "key: value".to_owned(), + }]); + let provider = MaterializingConfigProvider::new(catalog); + let registration = NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }; + + match provider.resolve(®istration) { + RepoResponse::Config(_) => panic!("expected not-ready error"), + RepoResponse::Error(error) => assert!(matches!(error.code, CfgSyncErrorCode::NotReady)), + } + } } diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index dbce99f..69dd758 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -1,10 +1,4 @@ -use std::{ - collections::{HashMap, HashSet}, - fs, - net::Ipv4Addr, - path::Path, - sync::{Arc, Mutex}, -}; +use std::{collections::HashMap, fs, net::Ipv4Addr, path::Path, sync::Arc}; use cfgsync_artifacts::ArtifactFile; use serde::{Deserialize, Serialize}; @@ -113,66 +107,44 @@ pub enum RegistrationResponse { pub trait ConfigProvider: Send + Sync { fn register(&self, registration: NodeRegistration) -> RegistrationResponse; - fn resolve(&self, identifier: &str) -> RepoResponse; + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse; } /// In-memory map-backed provider used by cfgsync server state. pub struct ConfigRepo { configs: HashMap, - registrations: Mutex>, } impl ConfigRepo { #[must_use] pub fn from_bundle(configs: HashMap) -> Arc { - Arc::new(Self { - configs, - registrations: Mutex::new(HashSet::new()), - }) - } - - fn register_identifier(&self, identifier: &str) -> RegistrationResponse { - if !self.configs.contains_key(identifier) { - return RegistrationResponse::Error(CfgSyncErrorResponse::missing_config(identifier)); - } - - let mut registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - registrations.insert(identifier.to_owned()); - - RegistrationResponse::Registered - } - - fn is_registered(&self, identifier: &str) -> bool { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - registrations.contains(identifier) + Arc::new(Self { configs }) } } impl ConfigProvider for ConfigRepo { fn register(&self, registration: NodeRegistration) -> RegistrationResponse { - self.register_identifier(®istration.identifier) + if self.configs.contains_key(®istration.identifier) { + RegistrationResponse::Registered + } else { + RegistrationResponse::Error(CfgSyncErrorResponse::missing_config( + ®istration.identifier, + )) + } } - fn resolve(&self, identifier: &str) -> RepoResponse { - if !self.configs.contains_key(identifier) { - return RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)); - } - - if !self.is_registered(identifier) { - return RepoResponse::Error(CfgSyncErrorResponse::not_ready(identifier)); - } - - self.configs.get(identifier).cloned().map_or_else( - || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), - RepoResponse::Config, - ) + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + self.configs + .get(®istration.identifier) + .cloned() + .map_or_else( + || { + RepoResponse::Error(CfgSyncErrorResponse::missing_config( + ®istration.identifier, + )) + }, + RepoResponse::Config, + ) } } @@ -219,10 +191,7 @@ impl FileConfigProvider { .collect(); Ok(Self { - inner: ConfigRepo { - configs, - registrations: Mutex::new(HashSet::new()), - }, + inner: ConfigRepo { configs }, }) } } @@ -232,8 +201,8 @@ impl ConfigProvider for FileConfigProvider { self.inner.register(registration) } - fn resolve(&self, identifier: &str) -> RepoResponse { - self.inner.resolve(identifier) + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + self.inner.resolve(registration) } } @@ -257,12 +226,12 @@ mod tests { fn resolves_existing_identifier() { let mut configs = HashMap::new(); configs.insert("node-1".to_owned(), sample_payload()); - let repo = ConfigRepo { - configs, - registrations: Mutex::new(HashSet::from(["node-1".to_owned()])), - }; + let repo = ConfigRepo { configs }; - match repo.resolve("node-1") { + match repo.resolve(&NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }) { RepoResponse::Config(payload) => { assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); assert_eq!(payload.files.len(), 1); @@ -276,10 +245,12 @@ mod tests { fn reports_missing_identifier() { let repo = ConfigRepo { configs: HashMap::new(), - registrations: Mutex::new(HashSet::new()), }; - match repo.resolve("unknown-node") { + match repo.resolve(&NodeRegistration { + identifier: "unknown-node".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }) { RepoResponse::Config(_) => panic!("expected missing-config error"), RepoResponse::Error(error) => { assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); @@ -310,26 +281,27 @@ nodes: ip: "127.0.0.1".parse().expect("parse ip"), }); - match provider.resolve("node-1") { + match provider.resolve(&NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }) { RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), RepoResponse::Error(error) => panic!("expected config, got {error}"), } } #[test] - fn resolve_requires_registration_first() { + fn resolve_accepts_known_registration_without_gating() { let mut configs = HashMap::new(); configs.insert("node-1".to_owned(), sample_payload()); - let repo = ConfigRepo { - configs, - registrations: Mutex::new(HashSet::new()), - }; + let repo = ConfigRepo { configs }; - match repo.resolve("node-1") { - RepoResponse::Config(_) => panic!("expected not-ready error"), - RepoResponse::Error(error) => { - assert!(matches!(error.code, CfgSyncErrorCode::NotReady)); - } + match repo.resolve(&NodeRegistration { + identifier: "node-1".to_owned(), + ip: "127.0.0.1".parse().expect("parse ip"), + }) { + RepoResponse::Config(_) => {} + RepoResponse::Error(error) => panic!("expected config, got {error}"), } } } diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 330c80d..407f30a 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -39,7 +39,7 @@ async fn node_config( State(state): State>, Json(payload): Json, ) -> impl IntoResponse { - let response = resolve_node_config_response(&state, &payload.identifier); + let response = resolve_node_config_response(&state, &payload); match response { RepoResponse::Config(payload_data) => (StatusCode::OK, Json(payload_data)).into_response(), @@ -65,8 +65,11 @@ async fn register_node( } } -fn resolve_node_config_response(state: &CfgSyncState, identifier: &str) -> RepoResponse { - state.repo.resolve(identifier) +fn resolve_node_config_response( + state: &CfgSyncState, + registration: &NodeRegistration, +) -> RepoResponse { + state.repo.resolve(registration) } fn error_status(code: &CfgSyncErrorCode) -> StatusCode { @@ -129,11 +132,66 @@ mod tests { } } - fn resolve(&self, identifier: &str) -> RepoResponse { - self.data.get(identifier).cloned().map_or_else( - || RepoResponse::Error(CfgSyncErrorResponse::missing_config(identifier)), - RepoResponse::Config, - ) + 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, + registrations: std::sync::Mutex>, + } + + 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, + ) } } @@ -149,7 +207,10 @@ mod tests { let mut data = HashMap::new(); data.insert("node-a".to_owned(), sample_payload()); - let provider = crate::repo::ConfigRepo::from_bundle(data); + let provider = Arc::new(RegistrationAwareProvider { + data, + registrations: std::sync::Mutex::new(HashMap::new()), + }); let state = Arc::new(CfgSyncState::new(provider)); let payload = NodeRegistration { ip: "127.0.0.1".parse().expect("valid ip"), @@ -197,7 +258,10 @@ mod tests { let mut data = HashMap::new(); data.insert("node-a".to_owned(), sample_payload()); - let provider = crate::repo::ConfigRepo::from_bundle(data); + let provider = Arc::new(RegistrationAwareProvider { + data, + registrations: std::sync::Mutex::new(HashMap::new()), + }); let state = Arc::new(CfgSyncState::new(provider)); let payload = NodeRegistration { ip: "127.0.0.1".parse().expect("valid ip"), From b775f7fd81fb562f7c43121e5008eb62de93bec4 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 09:18:29 +0100 Subject: [PATCH 04/38] Use materializing cfgsync provider at runtime --- Cargo.lock | 2 ++ cfgsync/adapter/Cargo.toml | 1 + cfgsync/adapter/src/lib.rs | 20 +++++++----- cfgsync/runtime/Cargo.toml | 17 +++++----- cfgsync/runtime/src/server.rs | 49 +++++++++++++++++++++++++--- logos/runtime/ext/src/cfgsync/mod.rs | 7 +++- 6 files changed, 73 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37f4acb..f9bf1be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,6 +922,7 @@ version = "0.1.0" dependencies = [ "cfgsync-artifacts", "cfgsync-core", + "serde", "thiserror 2.0.18", ] @@ -954,6 +955,7 @@ name = "cfgsync-runtime" version = "0.1.0" dependencies = [ "anyhow", + "cfgsync-adapter", "cfgsync-core", "clap", "serde", diff --git a/cfgsync/adapter/Cargo.toml b/cfgsync/adapter/Cargo.toml index 7a15ad1..bcc4da7 100644 --- a/cfgsync/adapter/Cargo.toml +++ b/cfgsync/adapter/Cargo.toml @@ -15,4 +15,5 @@ workspace = true [dependencies] cfgsync-artifacts = { workspace = true } cfgsync-core = { workspace = true } +serde = { workspace = true } thiserror = { workspace = true } diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index ff35b4e..d4da5ed 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -5,18 +5,19 @@ use cfgsync_core::{ CfgSyncErrorResponse, CfgSyncPayload, ConfigProvider, NodeRegistration, RegistrationResponse, RepoResponse, }; +use serde::{Deserialize, Serialize}; use thiserror::Error; /// Type-erased cfgsync adapter error used to preserve source context. pub type DynCfgsyncError = Box; /// Per-node rendered config output used to build cfgsync bundles. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CfgsyncNodeConfig { /// Stable node identifier resolved by the adapter. pub identifier: String, - /// Serialized config payload for the node. - pub config_yaml: String, + /// Files served to the node after cfgsync registration. + pub files: Vec, } /// Node artifacts produced by a cfgsync materializer. @@ -260,7 +261,7 @@ fn build_node_entry( Ok(CfgsyncNodeConfig { identifier: E::node_identifier(index, node), - config_yaml, + files: vec![ArtifactFile::new("/config.yaml", &config_yaml)], }) } @@ -278,11 +279,12 @@ fn build_rewritten_node_config( } fn build_node_artifacts_from_config(config: &CfgsyncNodeConfig) -> CfgsyncNodeArtifacts { - CfgsyncNodeArtifacts::new(vec![ArtifactFile::new("/config.yaml", &config.config_yaml)]) + CfgsyncNodeArtifacts::new(config.files.clone()) } #[cfg(test)] mod tests { + use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{CfgSyncErrorCode, ConfigProvider, NodeRegistration, RepoResponse}; use super::{CfgsyncNodeCatalog, CfgsyncNodeConfig, MaterializingConfigProvider}; @@ -291,19 +293,19 @@ mod tests { fn catalog_resolves_identifier() { let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { identifier: "node-1".to_owned(), - config_yaml: "key: value".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); let node = catalog.resolve("node-1").expect("resolve node config"); - assert_eq!(node.config_yaml, "key: value"); + assert_eq!(node.files[0].content, "key: value"); } #[test] fn materializing_provider_resolves_registered_node() { let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { identifier: "node-1".to_owned(), - config_yaml: "key: value".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); let provider = MaterializingConfigProvider::new(catalog); let registration = NodeRegistration { @@ -323,7 +325,7 @@ mod tests { fn materializing_provider_reports_not_ready_before_registration() { let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { identifier: "node-1".to_owned(), - config_yaml: "key: value".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); let provider = MaterializingConfigProvider::new(catalog); let registration = NodeRegistration { diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index 045bc73..f708ba1 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -13,14 +13,15 @@ version = { workspace = true } workspace = true [dependencies] -anyhow = "1" -cfgsync-core = { workspace = true } -clap = { version = "4", features = ["derive"] } -serde = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } -tracing = { workspace = true } +anyhow = "1" +cfgsync-adapter = { workspace = true } +cfgsync-core = { workspace = true } +clap = { version = "4", features = ["derive"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index a038a43..ba987f3 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -1,7 +1,8 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; -use cfgsync_core::{CfgSyncState, ConfigProvider, FileConfigProvider, run_cfgsync}; +use cfgsync_adapter::{CfgsyncNodeCatalog, MaterializingConfigProvider}; +use cfgsync_core::{CfgSyncBundle, CfgSyncState, ConfigProvider, FileConfigProvider, run_cfgsync}; use serde::Deserialize; /// Runtime cfgsync server config loaded from YAML. @@ -9,6 +10,8 @@ use serde::Deserialize; pub struct CfgSyncServerConfig { pub port: u16, pub bundle_path: String, + #[serde(default)] + pub registration_flow: bool, } impl CfgSyncServerConfig { @@ -22,13 +25,42 @@ impl CfgSyncServerConfig { } } -fn load_bundle(bundle_path: &Path) -> anyhow::Result> { +fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { let provider = FileConfigProvider::from_yaml_file(bundle_path) .with_context(|| format!("loading cfgsync provider from {}", bundle_path.display()))?; Ok(Arc::new(provider)) } +fn load_materializing_provider(bundle_path: &Path) -> anyhow::Result> { + let bundle = load_bundle_yaml(bundle_path)?; + let catalog = build_node_catalog(bundle); + let provider = MaterializingConfigProvider::new(catalog); + + Ok(Arc::new(provider)) +} + +fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { + let raw = fs::read_to_string(bundle_path) + .with_context(|| format!("reading cfgsync bundle from {}", bundle_path.display()))?; + + serde_yaml::from_str(&raw) + .with_context(|| format!("parsing cfgsync bundle from {}", bundle_path.display())) +} + +fn build_node_catalog(bundle: CfgSyncBundle) -> CfgsyncNodeCatalog { + let nodes = bundle + .nodes + .into_iter() + .map(|node| cfgsync_adapter::CfgsyncNodeConfig { + identifier: node.identifier, + files: node.files, + }) + .collect(); + + CfgsyncNodeCatalog::new(nodes) +} + fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { let path = Path::new(bundle_path); if path.is_absolute() { @@ -46,14 +78,21 @@ pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { let config = CfgSyncServerConfig::load_from_file(config_path)?; let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); - let state = build_server_state(&bundle_path)?; + let state = build_server_state(&config, &bundle_path)?; run_cfgsync(config.port, state).await?; Ok(()) } -fn build_server_state(bundle_path: &Path) -> anyhow::Result { - let repo = load_bundle(bundle_path)?; +fn build_server_state( + config: &CfgSyncServerConfig, + bundle_path: &Path, +) -> anyhow::Result { + let repo = if config.registration_flow { + load_materializing_provider(bundle_path)? + } else { + load_bundle_provider(bundle_path)? + }; Ok(CfgSyncState::new(repo)) } diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 074223d..f607a9c 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -54,7 +54,7 @@ fn build_cfgsync_bundle( .into_iter() .map(|node| CfgSyncBundleNode { identifier: node.identifier, - files: vec![build_bundle_file("/config.yaml", node.config_yaml)], + files: node.files, }) .collect(); @@ -121,6 +121,11 @@ fn build_cfgsync_server_config() -> Value { Value::String("cfgsync.bundle.yaml".to_string()), ); + root.insert( + Value::String("registration_flow".to_string()), + Value::Bool(true), + ); + Value::Mapping(root) } From 80e1fe6c6628ff8efd5d98f4a83241d2c8ab8385 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 09:41:03 +0100 Subject: [PATCH 05/38] Make cfgsync registration metadata generic --- Cargo.lock | 1 + cfgsync/adapter/src/lib.rs | 10 +-- cfgsync/core/src/lib.rs | 2 +- cfgsync/core/src/repo.rs | 141 +++++++++++++++++++++++++++++----- cfgsync/core/src/server.rs | 15 +--- cfgsync/runtime/Cargo.toml | 1 + cfgsync/runtime/src/client.rs | 60 +++++++++++++-- 7 files changed, 184 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9bf1be..2be427b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,6 +959,7 @@ dependencies = [ "cfgsync-core", "clap", "serde", + "serde_json", "serde_yaml", "tempfile", "thiserror 2.0.18", diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index d4da5ed..226b98d 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -308,10 +308,7 @@ mod tests { files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); let provider = MaterializingConfigProvider::new(catalog); - let registration = NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }; + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); let _ = provider.register(registration.clone()); @@ -328,10 +325,7 @@ mod tests { files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); let provider = MaterializingConfigProvider::new(catalog); - let registration = NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }; + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); match provider.resolve(®istration) { RepoResponse::Config(_) => panic!("expected not-ready error"), diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index 2807346..c33c32f 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -14,6 +14,6 @@ pub use render::{ pub use repo::{ CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration, - RegistrationResponse, RepoResponse, + RegistrationMetadata, RegistrationResponse, RepoResponse, }; pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index 69dd758..e301aca 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, fs, net::Ipv4Addr, path::Path, sync::Arc}; use cfgsync_artifacts::ArtifactFile; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use thiserror::Error; use crate::{CfgSyncBundle, CfgSyncBundleNode}; @@ -22,11 +23,84 @@ pub struct CfgSyncPayload { pub files: Vec, } +/// 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, +} + +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, value: Value) { + self.values.insert(key.into(), value); + } + + pub fn insert_serialized( + &mut self, + key: impl Into, + 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 { + &self.values + } +} + +impl From> for RegistrationMetadata { + fn from(values: Map) -> Self { + Self { values } + } +} + /// Node metadata recorded before config materialization. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct NodeRegistration { pub identifier: String, pub ip: Ipv4Addr, + #[serde(default, skip_serializing_if = "RegistrationMetadata::is_empty")] + pub metadata: RegistrationMetadata, +} + +impl NodeRegistration { + #[must_use] + pub fn new(identifier: impl Into, 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 { @@ -228,10 +302,10 @@ mod tests { configs.insert("node-1".to_owned(), sample_payload()); let repo = ConfigRepo { configs }; - match repo.resolve(&NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }) { + match repo.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { RepoResponse::Config(payload) => { assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); assert_eq!(payload.files.len(), 1); @@ -247,10 +321,10 @@ mod tests { configs: HashMap::new(), }; - match repo.resolve(&NodeRegistration { - identifier: "unknown-node".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }) { + match repo.resolve(&NodeRegistration::new( + "unknown-node", + "127.0.0.1".parse().expect("parse ip"), + )) { RepoResponse::Config(_) => panic!("expected missing-config error"), RepoResponse::Error(error) => { assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); @@ -276,15 +350,15 @@ nodes: let provider = FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider"); - let _ = provider.register(NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }); + let _ = provider.register(NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )); - match provider.resolve(&NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }) { + match provider.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), RepoResponse::Error(error) => panic!("expected config, got {error}"), } @@ -296,12 +370,39 @@ nodes: configs.insert("node-1".to_owned(), sample_payload()); let repo = ConfigRepo { configs }; - match repo.resolve(&NodeRegistration { - identifier: "node-1".to_owned(), - ip: "127.0.0.1".parse().expect("parse ip"), - }) { + match repo.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { RepoResponse::Config(_) => {} 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(®istration).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())) + ); + } } diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 407f30a..794af79 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -212,10 +212,7 @@ mod tests { registrations: std::sync::Mutex::new(HashMap::new()), }); 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 payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); let _ = register_node(State(state.clone()), Json(payload.clone())) .await @@ -234,10 +231,7 @@ mod tests { data: HashMap::new(), }); let state = Arc::new(CfgSyncState::new(provider)); - let payload = NodeRegistration { - ip: "127.0.0.1".parse().expect("valid ip"), - identifier: "missing-node".to_owned(), - }; + let payload = NodeRegistration::new("missing-node", "127.0.0.1".parse().expect("valid ip")); let response = node_config(State(state), Json(payload)) .await @@ -263,10 +257,7 @@ mod tests { registrations: std::sync::Mutex::new(HashMap::new()), }); 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 payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); let response = node_config(State(state), Json(payload)) .await diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index f708ba1..b6e7a5f 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -18,6 +18,7 @@ cfgsync-adapter = { workspace = true } cfgsync-core = { workspace = true } clap = { version = "4", features = ["derive"] } serde = { workspace = true } +serde_json = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 82dbe65..7cc113c 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -7,7 +7,9 @@ use std::{ use anyhow::{Context as _, Result, bail}; use cfgsync_core::{ CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration, + RegistrationMetadata, }; +use serde_json::Value; use thiserror::Error; use tokio::time::{Duration, sleep}; use tracing::info; @@ -19,6 +21,8 @@ const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); enum ClientEnvError { #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] 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 { @@ -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 identifier = 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 { @@ -158,6 +167,24 @@ fn parse_ip_env(ip_str: &str) -> Result { .map_err(Into::into) } +fn parse_registration_metadata_env() -> Result { + 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 { + 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)] mod tests { use std::collections::HashMap; @@ -192,10 +219,7 @@ mod tests { }); pull_config_files( - NodeRegistration { - ip: "127.0.0.1".parse().expect("parse ip"), - identifier: "node-1".to_owned(), - }, + NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), &address, ) .await @@ -230,4 +254,30 @@ mod tests { drop(listener); 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") + ); + } } From 13084c3a36f65a84fde48358ffb97f75136ce40a Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 09:56:12 +0100 Subject: [PATCH 06/38] Use typed cfgsync registration payloads --- cfgsync/core/src/lib.rs | 2 +- cfgsync/core/src/repo.rs | 288 ++++++++++++++++++++++------------ cfgsync/runtime/src/client.rs | 64 ++++---- 3 files changed, 217 insertions(+), 137 deletions(-) diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index c33c32f..e0b3969 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -14,6 +14,6 @@ pub use render::{ pub use repo::{ CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration, - RegistrationMetadata, RegistrationResponse, RepoResponse, + RegistrationPayload, RegistrationResponse, RepoResponse, }; pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index e301aca..15d1332 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -1,8 +1,8 @@ use std::{collections::HashMap, fs, net::Ipv4Addr, path::Path, sync::Arc}; use cfgsync_artifacts::ArtifactFile; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; +use serde_json::Value; use thiserror::Error; use crate::{CfgSyncBundle, CfgSyncBundleNode}; @@ -23,15 +23,13 @@ pub struct CfgSyncPayload { pub files: Vec, } -/// 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, +/// Adapter-owned registration payload stored alongside a generic node identity. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RegistrationPayload { + raw_json: Option, } -impl RegistrationMetadata { +impl RegistrationPayload { #[must_use] pub fn new() -> Self { Self::default() @@ -39,41 +37,69 @@ impl RegistrationMetadata { #[must_use] pub fn is_empty(&self) -> bool { - self.values.is_empty() + self.raw_json.is_none() } - #[must_use] - pub fn get(&self, key: &str) -> Option<&Value> { - self.values.get(key) - } - - pub fn insert_json_value(&mut self, key: impl Into, value: Value) { - self.values.insert(key.into(), value); - } - - pub fn insert_serialized( - &mut self, - key: impl Into, - value: T, - ) -> Result<(), serde_json::Error> + pub fn from_serializable(value: &T) -> Result where T: Serialize, { - let value = serde_json::to_value(value)?; - self.insert_json_value(key, value); + Ok(Self { + raw_json: Some(serde_json::to_string(value)?), + }) + } - Ok(()) + pub fn from_json_str(raw_json: &str) -> Result { + let value: Value = serde_json::from_str(raw_json)?; + + Ok(Self { + raw_json: Some(serde_json::to_string(&value)?), + }) + } + + pub fn deserialize(&self) -> Result, serde_json::Error> + where + T: DeserializeOwned, + { + self.raw_json + .as_ref() + .map(|raw_json| serde_json::from_str(raw_json)) + .transpose() } #[must_use] - pub fn values(&self) -> &Map { - &self.values + pub fn raw_json(&self) -> Option<&str> { + self.raw_json.as_deref() } } -impl From> for RegistrationMetadata { - fn from(values: Map) -> Self { - Self { values } +impl Serialize for RegistrationPayload { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.raw_json.as_deref() { + Some(raw_json) => { + let value: Value = + serde_json::from_str(raw_json).map_err(serde::ser::Error::custom)?; + value.serialize(serializer) + } + None => serializer.serialize_none(), + } + } +} + +impl<'de> Deserialize<'de> for RegistrationPayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + let raw_json = value + .map(|value| serde_json::to_string(&value).map_err(serde::de::Error::custom)) + .transpose()?; + + Ok(Self { raw_json }) } } @@ -82,8 +108,8 @@ impl From> for RegistrationMetadata { pub struct NodeRegistration { pub identifier: String, pub ip: Ipv4Addr, - #[serde(default, skip_serializing_if = "RegistrationMetadata::is_empty")] - pub metadata: RegistrationMetadata, + #[serde(default, skip_serializing_if = "RegistrationPayload::is_empty")] + pub metadata: RegistrationPayload, } impl NodeRegistration { @@ -92,13 +118,21 @@ impl NodeRegistration { Self { identifier: identifier.into(), ip, - metadata: RegistrationMetadata::default(), + metadata: RegistrationPayload::default(), } } + pub fn with_metadata(mut self, metadata: &T) -> Result + where + T: Serialize, + { + self.metadata = RegistrationPayload::from_serializable(metadata)?; + Ok(self) + } + #[must_use] - pub fn with_metadata(mut self, metadata: RegistrationMetadata) -> Self { - self.metadata = metadata; + pub fn with_payload(mut self, payload: RegistrationPayload) -> Self { + self.metadata = payload; self } } @@ -222,66 +256,45 @@ impl ConfigProvider for ConfigRepo { } } -/// Failures when loading a file-backed cfgsync provider. #[derive(Debug, Error)] -pub enum FileConfigProviderError { - #[error("failed to read cfgsync bundle at {path}: {source}")] - Read { +pub enum BundleLoadError { + #[error("reading cfgsync bundle {path}: {source}")] + ReadBundle { path: String, #[source] source: std::io::Error, }, - #[error("failed to parse cfgsync bundle at {path}: {source}")] - Parse { + #[error("parsing cfgsync bundle {path}: {source}")] + ParseBundle { path: String, #[source] source: serde_yaml::Error, }, } -/// YAML bundle-backed provider implementation. -pub struct FileConfigProvider { - inner: ConfigRepo, -} +#[must_use] +pub fn bundle_to_payload_map(bundle: CfgSyncBundle) -> HashMap { + bundle + .nodes + .into_iter() + .map(|node| { + let CfgSyncBundleNode { identifier, files } = node; -impl FileConfigProvider { - /// Loads provider state from a cfgsync bundle YAML file. - pub fn from_yaml_file(path: &Path) -> Result { - let raw = fs::read_to_string(path).map_err(|source| FileConfigProviderError::Read { - path: path.display().to_string(), - source, - })?; - - let bundle: CfgSyncBundle = - serde_yaml::from_str(&raw).map_err(|source| FileConfigProviderError::Parse { - path: path.display().to_string(), - source, - })?; - - let configs = bundle - .nodes - .into_iter() - .map(payload_from_bundle_node) - .collect(); - - Ok(Self { - inner: ConfigRepo { configs }, + (identifier, CfgSyncPayload::from_files(files)) }) - } + .collect() } -impl ConfigProvider for FileConfigProvider { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { - self.inner.register(registration) - } - - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { - self.inner.resolve(registration) - } -} - -fn payload_from_bundle_node(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { - (node.identifier, CfgSyncPayload::from_files(node.files)) +pub fn load_bundle(path: &Path) -> Result { + let path_string = path.display().to_string(); + let raw = fs::read_to_string(path).map_err(|source| BundleLoadError::ReadBundle { + path: path_string.clone(), + source, + })?; + serde_yaml::from_str(&raw).map_err(|source| BundleLoadError::ParseBundle { + path: path_string, + source, + }) } #[cfg(test)] @@ -292,6 +305,38 @@ mod tests { use super::*; + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct ExampleRegistration { + network_port: u16, + service: String, + } + + #[test] + fn registration_payload_round_trips_typed_value() { + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")) + .with_metadata(&ExampleRegistration { + network_port: 3000, + service: "blend".to_owned(), + }) + .expect("serialize registration metadata"); + + let encoded = serde_json::to_value(®istration).expect("serialize registration"); + let metadata = encoded.get("metadata").expect("registration metadata"); + assert_eq!(metadata.get("network_port"), Some(&Value::from(3000u16))); + assert_eq!(metadata.get("service"), Some(&Value::from("blend"))); + + let decoded: NodeRegistration = + serde_json::from_value(encoded).expect("deserialize registration"); + let typed: ExampleRegistration = decoded + .metadata + .deserialize() + .expect("deserialize metadata") + .expect("registration metadata value"); + + assert_eq!(typed.network_port, 3000); + assert_eq!(typed.service, "blend"); + } + fn sample_payload() -> CfgSyncPayload { CfgSyncPayload::from_files(vec![CfgSyncFile::new("/config.yaml", "key: value")]) } @@ -378,31 +423,66 @@ nodes: 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())); +/// Failures when loading a file-backed cfgsync provider. +#[derive(Debug, Error)] +pub enum FileConfigProviderError { + #[error("failed to read cfgsync bundle at {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse cfgsync bundle at {path}: {source}")] + Parse { + path: String, + #[source] + source: serde_yaml::Error, + }, +} - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")) - .with_metadata(metadata); +/// YAML bundle-backed provider implementation. +pub struct FileConfigProvider { + inner: ConfigRepo, +} - let encoded = serde_json::to_value(®istration).expect("serialize registration"); - let metadata = encoded - .get("metadata") - .and_then(Value::as_object) - .expect("registration metadata object"); +impl FileConfigProvider { + /// Loads provider state from a cfgsync bundle YAML file. + pub fn from_yaml_file(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| FileConfigProviderError::Read { + path: path.display().to_string(), + source, + })?; - assert_eq!( - metadata.get("network_port"), - Some(&Value::Number(3000_u16.into())) - ); - assert_eq!( - metadata.get("service"), - Some(&Value::String("blend".to_owned())) - ); + let bundle: CfgSyncBundle = + serde_yaml::from_str(&raw).map_err(|source| FileConfigProviderError::Parse { + path: path.display().to_string(), + source, + })?; + + let configs = bundle + .nodes + .into_iter() + .map(payload_from_bundle_node) + .collect(); + + Ok(Self { + inner: ConfigRepo { configs }, + }) } } + +impl ConfigProvider for FileConfigProvider { + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + self.inner.register(registration) + } + + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + self.inner.resolve(registration) + } +} + +fn payload_from_bundle_node(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { + (node.identifier, CfgSyncPayload::from_files(node.files)) +} diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 7cc113c..00af686 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -7,9 +7,8 @@ use std::{ use anyhow::{Context as _, Result, bail}; use cfgsync_core::{ CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration, - RegistrationMetadata, + RegistrationPayload, }; -use serde_json::Value; use thiserror::Error; use tokio::time::{Duration, sleep}; use tracing::info; @@ -21,8 +20,6 @@ const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); enum ClientEnvError { #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] 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 { @@ -149,10 +146,10 @@ 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 identifier = env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); - let metadata = parse_registration_metadata_env()?; + let metadata = parse_registration_payload_env()?; pull_config_files( - NodeRegistration::new(identifier, ip).with_metadata(metadata), + NodeRegistration::new(identifier, ip).with_payload(metadata), &server_addr, ) .await @@ -167,22 +164,16 @@ fn parse_ip_env(ip_str: &str) -> Result { .map_err(Into::into) } -fn parse_registration_metadata_env() -> Result { +fn parse_registration_payload_env() -> Result { let Ok(raw) = env::var("CFG_REGISTRATION_METADATA_JSON") else { - return Ok(RegistrationMetadata::default()); + return Ok(RegistrationPayload::default()); }; - parse_registration_metadata(&raw) + parse_registration_payload(&raw) } -fn parse_registration_metadata(raw: &str) -> Result { - 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())) +fn parse_registration_payload(raw: &str) -> Result { + RegistrationPayload::from_json_str(raw).context("parsing CFG_REGISTRATION_METADATA_JSON") } #[cfg(test)] @@ -256,28 +247,37 @@ mod tests { } #[test] - fn parses_registration_metadata_object() { - let metadata = parse_registration_metadata(r#"{"network_port":3000,"service":"blend"}"#) + fn parses_registration_payload_object() { + #[derive(Debug, serde::Deserialize, PartialEq, Eq)] + struct ExamplePayload { + network_port: u16, + service: String, + } + + let metadata = parse_registration_payload(r#"{"network_port":3000,"service":"blend"}"#) .expect("parse metadata"); + let payload: ExamplePayload = metadata + .deserialize() + .expect("deserialize payload") + .expect("payload value"); assert_eq!( - metadata.get("network_port"), - Some(&Value::Number(3000_u16.into())) - ); - assert_eq!( - metadata.get("service"), - Some(&Value::String("blend".to_owned())) + payload, + ExamplePayload { + network_port: 3000, + service: "blend".to_owned(), + } ); } #[test] - fn rejects_non_object_registration_metadata() { - let error = parse_registration_metadata(r#"[1,2,3]"#).expect_err("reject metadata array"); + fn parses_registration_payload_array() { + let metadata = parse_registration_payload(r#"[1,2,3]"#).expect("parse metadata array"); + let payload: Vec = metadata + .deserialize() + .expect("deserialize payload") + .expect("payload value"); - assert!( - error - .to_string() - .contains("CFG_REGISTRATION_METADATA_JSON must be a JSON object") - ); + assert_eq!(payload, vec![1, 2, 3]); } } From 312dec6178a75289555849ba6016f886a530ce48 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 10:20:30 +0100 Subject: [PATCH 07/38] Pass registration snapshots into cfgsync materializers --- cfgsync/adapter/src/lib.rs | 108 ++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 226b98d..56d96bd 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -26,6 +26,41 @@ pub struct CfgsyncNodeArtifacts { files: Vec, } +/// Immutable view of registrations currently known to cfgsync. +#[derive(Debug, Clone, Default)] +pub struct RegistrationSnapshot { + registrations: Vec, +} + +impl RegistrationSnapshot { + #[must_use] + pub fn new(registrations: Vec) -> Self { + Self { registrations } + } + + #[must_use] + pub fn len(&self) -> usize { + self.registrations.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.registrations.is_empty() + } + + #[must_use] + pub fn iter(&self) -> impl Iterator { + self.registrations.iter() + } + + #[must_use] + pub fn get(&self, identifier: &str) -> Option<&NodeRegistration> { + self.registrations + .iter() + .find(|registration| registration.identifier == identifier) + } +} + impl CfgsyncNodeArtifacts { #[must_use] pub fn new(files: Vec) -> Self { @@ -86,6 +121,7 @@ pub trait CfgsyncMaterializer: Send + Sync { fn materialize( &self, registration: &NodeRegistration, + registrations: &RegistrationSnapshot, ) -> Result, DynCfgsyncError>; } @@ -93,6 +129,7 @@ impl CfgsyncMaterializer for CfgsyncNodeCatalog { fn materialize( &self, registration: &NodeRegistration, + _registrations: &RegistrationSnapshot, ) -> Result, DynCfgsyncError> { let artifacts = self .resolve(®istration.identifier) @@ -125,6 +162,15 @@ impl MaterializingConfigProvider { registrations.get(identifier).cloned() } + + fn registration_snapshot(&self) -> RegistrationSnapshot { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + RegistrationSnapshot::new(registrations.values().cloned().collect()) + } } impl ConfigProvider for MaterializingConfigProvider @@ -150,8 +196,9 @@ where )); } }; + let registrations = self.registration_snapshot(); - match self.materializer.materialize(®istration) { + match self.materializer.materialize(®istration, ®istrations) { Ok(Some(artifacts)) => { RepoResponse::Config(CfgSyncPayload::from_files(artifacts.files().to_vec())) } @@ -284,10 +331,15 @@ fn build_node_artifacts_from_config(config: &CfgsyncNodeConfig) -> CfgsyncNodeAr #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{CfgSyncErrorCode, ConfigProvider, NodeRegistration, RepoResponse}; - use super::{CfgsyncNodeCatalog, CfgsyncNodeConfig, MaterializingConfigProvider}; + use super::{ + CfgsyncMaterializer, CfgsyncNodeArtifacts, CfgsyncNodeCatalog, CfgsyncNodeConfig, + DynCfgsyncError, MaterializingConfigProvider, RegistrationSnapshot, + }; #[test] fn catalog_resolves_identifier() { @@ -332,4 +384,56 @@ mod tests { RepoResponse::Error(error) => assert!(matches!(error.code, CfgSyncErrorCode::NotReady)), } } + + struct ThresholdMaterializer { + calls: AtomicUsize, + } + + impl CfgsyncMaterializer for ThresholdMaterializer { + fn materialize( + &self, + registration: &NodeRegistration, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + self.calls.fetch_add(1, Ordering::SeqCst); + + if registrations.len() < 2 { + return Ok(None); + } + + let peer_count = registrations.iter().count(); + let files = vec![ + ArtifactFile::new("/config.yaml", format!("id: {}", registration.identifier)), + ArtifactFile::new("/shared.yaml", format!("peers: {peer_count}")), + ]; + + Ok(Some(CfgsyncNodeArtifacts::new(files))) + } + } + + #[test] + fn materializing_provider_uses_registration_snapshot_for_readiness() { + let provider = MaterializingConfigProvider::new(ThresholdMaterializer { + calls: AtomicUsize::new(0), + }); + let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); + let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + + let _ = provider.register(node_a.clone()); + + match provider.resolve(&node_a) { + RepoResponse::Config(_) => panic!("expected not-ready error"), + RepoResponse::Error(error) => assert!(matches!(error.code, CfgSyncErrorCode::NotReady)), + } + + let _ = provider.register(node_b); + + match provider.resolve(&node_a) { + RepoResponse::Config(payload) => { + assert_eq!(payload.files()[0].content, "id: node-a"); + assert_eq!(payload.files()[1].content, "peers: 2"); + } + RepoResponse::Error(error) => panic!("expected config, got {error}"), + } + } } From f1e9eef4e0c4cef4a649d06c4b1adf32ae3835d2 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 10:25:21 +0100 Subject: [PATCH 08/38] Add snapshot-backed cfgsync materializers --- cfgsync/adapter/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 56d96bd..f5b24d5 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -125,6 +125,15 @@ pub trait CfgsyncMaterializer: Send + Sync { ) -> Result, DynCfgsyncError>; } +/// Adapter contract for materializing a whole registration snapshot into +/// per-node cfgsync artifacts. +pub trait CfgsyncSnapshotMaterializer: Send + Sync { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError>; +} + impl CfgsyncMaterializer for CfgsyncNodeCatalog { fn materialize( &self, @@ -139,6 +148,15 @@ impl CfgsyncMaterializer for CfgsyncNodeCatalog { } } +impl CfgsyncSnapshotMaterializer for CfgsyncNodeCatalog { + fn materialize_snapshot( + &self, + _registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + Ok(Some(self.clone())) + } +} + /// Registration-aware provider backed by an adapter materializer. pub struct MaterializingConfigProvider { materializer: M, @@ -173,6 +191,88 @@ impl MaterializingConfigProvider { } } +/// Registration-aware provider backed by a snapshot materializer. +pub struct SnapshotMaterializingConfigProvider { + materializer: M, + registrations: Mutex>, +} + +impl SnapshotMaterializingConfigProvider { + #[must_use] + pub fn new(materializer: M) -> Self { + Self { + materializer, + registrations: Mutex::new(HashMap::new()), + } + } + + fn registration_for(&self, identifier: &str) -> Option { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + registrations.get(identifier).cloned() + } + + fn registration_snapshot(&self) -> RegistrationSnapshot { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + RegistrationSnapshot::new(registrations.values().cloned().collect()) + } +} + +impl ConfigProvider for SnapshotMaterializingConfigProvider +where + M: CfgsyncSnapshotMaterializer, +{ + fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + let mut registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + registrations.insert(registration.identifier.clone(), registration); + + RegistrationResponse::Registered + } + + fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + let registration = match self.registration_for(®istration.identifier) { + Some(registration) => registration, + None => { + return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + }; + + let registrations = self.registration_snapshot(); + let catalog = match self.materializer.materialize_snapshot(®istrations) { + Ok(Some(catalog)) => catalog, + Ok(None) => { + return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + Err(error) => { + return RepoResponse::Error(CfgSyncErrorResponse::internal(format!( + "failed to materialize config snapshot: {error}" + ))); + } + }; + + match catalog.resolve(®istration.identifier) { + Some(config) => RepoResponse::Config(CfgSyncPayload::from_files(config.files.clone())), + None => RepoResponse::Error(CfgSyncErrorResponse::missing_config( + ®istration.identifier, + )), + } + } +} + impl ConfigProvider for MaterializingConfigProvider where M: CfgsyncMaterializer, From a751d819ea24fdea4ac81b46f1ee3d0b3c393aa3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 10:56:42 +0100 Subject: [PATCH 09/38] Polish cfgsync adapter naming for external use --- cfgsync/adapter/src/lib.rs | 175 +++++++++++++++++++++------------- cfgsync/runtime/src/server.rs | 10 +- 2 files changed, 116 insertions(+), 69 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index f5b24d5..2725d13 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -11,28 +11,28 @@ use thiserror::Error; /// Type-erased cfgsync adapter error used to preserve source context. pub type DynCfgsyncError = Box; -/// Per-node rendered config output used to build cfgsync bundles. +/// Per-node artifact payload served by cfgsync for one registered node. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgsyncNodeConfig { +pub struct NodeArtifacts { /// Stable node identifier resolved by the adapter. pub identifier: String, /// Files served to the node after cfgsync registration. pub files: Vec, } -/// Node artifacts produced by a cfgsync materializer. +/// Materialized artifact files for a single registered node. #[derive(Debug, Clone, Default)] -pub struct CfgsyncNodeArtifacts { +pub struct ArtifactSet { files: Vec, } /// Immutable view of registrations currently known to cfgsync. #[derive(Debug, Clone, Default)] -pub struct RegistrationSnapshot { +pub struct RegistrationSet { registrations: Vec, } -impl RegistrationSnapshot { +impl RegistrationSet { #[must_use] pub fn new(registrations: Vec) -> Self { Self { registrations } @@ -61,7 +61,7 @@ impl RegistrationSnapshot { } } -impl CfgsyncNodeArtifacts { +impl ArtifactSet { #[must_use] pub fn new(files: Vec) -> Self { Self { files } @@ -78,15 +78,15 @@ impl CfgsyncNodeArtifacts { } } -/// Precomputed node configs indexed by stable identifier. +/// Artifact payloads indexed by stable node identifier. #[derive(Debug, Clone, Default)] -pub struct CfgsyncNodeCatalog { - nodes: HashMap, +pub struct NodeArtifactsCatalog { + nodes: HashMap, } -impl CfgsyncNodeCatalog { +impl NodeArtifactsCatalog { #[must_use] - pub fn new(nodes: Vec) -> Self { + pub fn new(nodes: Vec) -> Self { let nodes = nodes .into_iter() .map(|node| (node.identifier.clone(), node)) @@ -96,7 +96,7 @@ impl CfgsyncNodeCatalog { } #[must_use] - pub fn resolve(&self, identifier: &str) -> Option<&CfgsyncNodeConfig> { + pub fn resolve(&self, identifier: &str) -> Option<&NodeArtifacts> { self.nodes.get(identifier) } @@ -111,35 +111,51 @@ impl CfgsyncNodeCatalog { } #[must_use] - pub fn into_configs(self) -> Vec { + pub fn into_nodes(self) -> Vec { self.nodes.into_values().collect() } + + #[doc(hidden)] + #[must_use] + pub fn into_configs(self) -> Vec { + self.into_nodes() + } } -/// Adapter-side node config materialization contract used by cfgsync server. -pub trait CfgsyncMaterializer: Send + Sync { +/// Adapter-side materialization contract for a single registered node. +pub trait NodeArtifactsMaterializer: Send + Sync { fn materialize( &self, registration: &NodeRegistration, - registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError>; + registrations: &RegistrationSet, + ) -> Result, DynCfgsyncError>; } -/// Adapter contract for materializing a whole registration snapshot into +/// Backward-compatible alias for the previous materializer trait name. +pub trait CfgsyncMaterializer: NodeArtifactsMaterializer {} + +impl CfgsyncMaterializer for T where T: NodeArtifactsMaterializer + ?Sized {} + +/// Adapter contract for materializing a whole registration set into /// per-node cfgsync artifacts. -pub trait CfgsyncSnapshotMaterializer: Send + Sync { +pub trait RegistrationSetMaterializer: Send + Sync { fn materialize_snapshot( &self, - registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError>; + registrations: &RegistrationSet, + ) -> Result, DynCfgsyncError>; } -impl CfgsyncMaterializer for CfgsyncNodeCatalog { +/// Backward-compatible alias for the previous snapshot materializer trait name. +pub trait CfgsyncSnapshotMaterializer: RegistrationSetMaterializer {} + +impl CfgsyncSnapshotMaterializer for T where T: RegistrationSetMaterializer + ?Sized {} + +impl NodeArtifactsMaterializer for NodeArtifactsCatalog { fn materialize( &self, registration: &NodeRegistration, - _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + _registrations: &RegistrationSet, + ) -> Result, DynCfgsyncError> { let artifacts = self .resolve(®istration.identifier) .map(build_node_artifacts_from_config); @@ -148,22 +164,22 @@ impl CfgsyncMaterializer for CfgsyncNodeCatalog { } } -impl CfgsyncSnapshotMaterializer for CfgsyncNodeCatalog { +impl RegistrationSetMaterializer for NodeArtifactsCatalog { fn materialize_snapshot( &self, - _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + _registrations: &RegistrationSet, + ) -> Result, DynCfgsyncError> { Ok(Some(self.clone())) } } /// Registration-aware provider backed by an adapter materializer. -pub struct MaterializingConfigProvider { +pub struct RegistrationConfigProvider { materializer: M, registrations: Mutex>, } -impl MaterializingConfigProvider { +impl RegistrationConfigProvider { #[must_use] pub fn new(materializer: M) -> Self { Self { @@ -181,23 +197,23 @@ impl MaterializingConfigProvider { registrations.get(identifier).cloned() } - fn registration_snapshot(&self) -> RegistrationSnapshot { + fn registration_set(&self) -> RegistrationSet { let registrations = self .registrations .lock() .expect("cfgsync registration store should not be poisoned"); - RegistrationSnapshot::new(registrations.values().cloned().collect()) + RegistrationSet::new(registrations.values().cloned().collect()) } } /// Registration-aware provider backed by a snapshot materializer. -pub struct SnapshotMaterializingConfigProvider { +pub struct SnapshotConfigProvider { materializer: M, registrations: Mutex>, } -impl SnapshotMaterializingConfigProvider { +impl SnapshotConfigProvider { #[must_use] pub fn new(materializer: M) -> Self { Self { @@ -215,19 +231,19 @@ impl SnapshotMaterializingConfigProvider { registrations.get(identifier).cloned() } - fn registration_snapshot(&self) -> RegistrationSnapshot { + fn registration_set(&self) -> RegistrationSet { let registrations = self .registrations .lock() .expect("cfgsync registration store should not be poisoned"); - RegistrationSnapshot::new(registrations.values().cloned().collect()) + RegistrationSet::new(registrations.values().cloned().collect()) } } -impl ConfigProvider for SnapshotMaterializingConfigProvider +impl ConfigProvider for SnapshotConfigProvider where - M: CfgsyncSnapshotMaterializer, + M: RegistrationSetMaterializer, { fn register(&self, registration: NodeRegistration) -> RegistrationResponse { let mut registrations = self @@ -249,7 +265,7 @@ where } }; - let registrations = self.registration_snapshot(); + let registrations = self.registration_set(); let catalog = match self.materializer.materialize_snapshot(®istrations) { Ok(Some(catalog)) => catalog, Ok(None) => { @@ -273,9 +289,9 @@ where } } -impl ConfigProvider for MaterializingConfigProvider +impl ConfigProvider for RegistrationConfigProvider where - M: CfgsyncMaterializer, + M: NodeArtifactsMaterializer, { fn register(&self, registration: NodeRegistration) -> RegistrationResponse { let mut registrations = self @@ -296,7 +312,7 @@ where )); } }; - let registrations = self.registration_snapshot(); + let registrations = self.registration_set(); match self.materializer.materialize(®istration, ®istrations) { Ok(Some(artifacts)) => { @@ -340,6 +356,11 @@ pub trait CfgsyncEnv { fn serialize_node_config(config: &Self::NodeConfig) -> Result; } +/// Preferred public name for application-side cfgsync integration. +pub trait DeploymentAdapter: CfgsyncEnv {} + +impl DeploymentAdapter for T where T: CfgsyncEnv + ?Sized {} + /// High-level failures while building adapter output for cfgsync. #[derive(Debug, Error)] pub enum BuildCfgsyncNodesError { @@ -370,14 +391,14 @@ pub fn build_cfgsync_node_configs( deployment: &E::Deployment, hostnames: &[String], ) -> Result, BuildCfgsyncNodesError> { - Ok(build_cfgsync_node_catalog::(deployment, hostnames)?.into_configs()) + Ok(build_node_artifact_catalog::(deployment, hostnames)?.into_nodes()) } /// Builds cfgsync node configs and indexes them by stable identifier. -pub fn build_cfgsync_node_catalog( +pub fn build_node_artifact_catalog( deployment: &E::Deployment, hostnames: &[String], -) -> Result { +) -> Result { let nodes = E::nodes(deployment); ensure_hostname_count(nodes.len(), hostnames.len())?; @@ -386,7 +407,15 @@ pub fn build_cfgsync_node_catalog( output.push(build_node_entry::(deployment, node, index, hostnames)?); } - Ok(CfgsyncNodeCatalog::new(output)) + Ok(NodeArtifactsCatalog::new(output)) +} + +#[doc(hidden)] +pub fn build_cfgsync_node_catalog( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result { + build_node_artifact_catalog::(deployment, hostnames) } fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { @@ -397,22 +426,22 @@ fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgs Ok(()) } -fn build_node_entry( +fn build_node_entry( deployment: &E::Deployment, node: &E::Node, index: usize, hostnames: &[String], -) -> Result { +) -> Result { let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; - Ok(CfgsyncNodeConfig { + Ok(NodeArtifacts { identifier: E::node_identifier(index, node), files: vec![ArtifactFile::new("/config.yaml", &config_yaml)], }) } -fn build_rewritten_node_config( +fn build_rewritten_node_config( deployment: &E::Deployment, node: &E::Node, index: usize, @@ -425,10 +454,28 @@ fn build_rewritten_node_config( Ok(node_config) } -fn build_node_artifacts_from_config(config: &CfgsyncNodeConfig) -> CfgsyncNodeArtifacts { - CfgsyncNodeArtifacts::new(config.files.clone()) +fn build_node_artifacts_from_config(config: &NodeArtifacts) -> ArtifactSet { + ArtifactSet::new(config.files.clone()) } +#[doc(hidden)] +pub type CfgsyncNodeConfig = NodeArtifacts; + +#[doc(hidden)] +pub type CfgsyncNodeArtifacts = ArtifactSet; + +#[doc(hidden)] +pub type RegistrationSnapshot = RegistrationSet; + +#[doc(hidden)] +pub type CfgsyncNodeCatalog = NodeArtifactsCatalog; + +#[doc(hidden)] +pub type MaterializingConfigProvider = RegistrationConfigProvider; + +#[doc(hidden)] +pub type SnapshotMaterializingConfigProvider = SnapshotConfigProvider; + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -437,13 +484,13 @@ mod tests { use cfgsync_core::{CfgSyncErrorCode, ConfigProvider, NodeRegistration, RepoResponse}; use super::{ - CfgsyncMaterializer, CfgsyncNodeArtifacts, CfgsyncNodeCatalog, CfgsyncNodeConfig, - DynCfgsyncError, MaterializingConfigProvider, RegistrationSnapshot, + ArtifactSet, DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, + NodeArtifactsMaterializer, RegistrationConfigProvider, RegistrationSet, }; #[test] fn catalog_resolves_identifier() { - let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { identifier: "node-1".to_owned(), files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); @@ -455,11 +502,11 @@ mod tests { #[test] fn materializing_provider_resolves_registered_node() { - let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { identifier: "node-1".to_owned(), files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); - let provider = MaterializingConfigProvider::new(catalog); + let provider = RegistrationConfigProvider::new(catalog); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); let _ = provider.register(registration.clone()); @@ -472,11 +519,11 @@ mod tests { #[test] fn materializing_provider_reports_not_ready_before_registration() { - let catalog = CfgsyncNodeCatalog::new(vec![CfgsyncNodeConfig { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { identifier: "node-1".to_owned(), files: vec![ArtifactFile::new("/config.yaml", "key: value")], }]); - let provider = MaterializingConfigProvider::new(catalog); + let provider = RegistrationConfigProvider::new(catalog); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); match provider.resolve(®istration) { @@ -489,12 +536,12 @@ mod tests { calls: AtomicUsize, } - impl CfgsyncMaterializer for ThresholdMaterializer { + impl NodeArtifactsMaterializer for ThresholdMaterializer { fn materialize( &self, registration: &NodeRegistration, - registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + registrations: &RegistrationSet, + ) -> Result, DynCfgsyncError> { self.calls.fetch_add(1, Ordering::SeqCst); if registrations.len() < 2 { @@ -507,13 +554,13 @@ mod tests { ArtifactFile::new("/shared.yaml", format!("peers: {peer_count}")), ]; - Ok(Some(CfgsyncNodeArtifacts::new(files))) + Ok(Some(ArtifactSet::new(files))) } } #[test] fn materializing_provider_uses_registration_snapshot_for_readiness() { - let provider = MaterializingConfigProvider::new(ThresholdMaterializer { + let provider = RegistrationConfigProvider::new(ThresholdMaterializer { calls: AtomicUsize::new(0), }); let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index ba987f3..16927d2 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -1,7 +1,7 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; -use cfgsync_adapter::{CfgsyncNodeCatalog, MaterializingConfigProvider}; +use cfgsync_adapter::{NodeArtifacts, NodeArtifactsCatalog, RegistrationConfigProvider}; use cfgsync_core::{CfgSyncBundle, CfgSyncState, ConfigProvider, FileConfigProvider, run_cfgsync}; use serde::Deserialize; @@ -35,7 +35,7 @@ fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result> { let bundle = load_bundle_yaml(bundle_path)?; let catalog = build_node_catalog(bundle); - let provider = MaterializingConfigProvider::new(catalog); + let provider = RegistrationConfigProvider::new(catalog); Ok(Arc::new(provider)) } @@ -48,17 +48,17 @@ fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { .with_context(|| format!("parsing cfgsync bundle from {}", bundle_path.display())) } -fn build_node_catalog(bundle: CfgSyncBundle) -> CfgsyncNodeCatalog { +fn build_node_catalog(bundle: CfgSyncBundle) -> NodeArtifactsCatalog { let nodes = bundle .nodes .into_iter() - .map(|node| cfgsync_adapter::CfgsyncNodeConfig { + .map(|node| NodeArtifacts { identifier: node.identifier, files: node.files, }) .collect(); - CfgsyncNodeCatalog::new(nodes) + NodeArtifactsCatalog::new(nodes) } fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { From 65fb8da5a59d5f35d081dd5d5b891a31db6cb2fc Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 11:03:51 +0100 Subject: [PATCH 10/38] Refine cfgsync core naming for external use --- cfgsync/adapter/src/lib.rs | 74 ++++++++++++++---------- cfgsync/core/src/lib.rs | 15 +++-- cfgsync/core/src/repo.rs | 106 ++++++++++++++++++++-------------- cfgsync/core/src/server.rs | 76 +++++++++++++----------- cfgsync/runtime/src/client.rs | 7 ++- cfgsync/runtime/src/server.rs | 14 +++-- 6 files changed, 169 insertions(+), 123 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 2725d13..6df51e9 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -2,8 +2,8 @@ use std::{collections::HashMap, error::Error, sync::Mutex}; use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{ - CfgSyncErrorResponse, CfgSyncPayload, ConfigProvider, NodeRegistration, RegistrationResponse, - RepoResponse, + CfgSyncErrorResponse, CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, + NodeRegistration, RegisterNodeResponse, }; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -241,25 +241,25 @@ impl SnapshotConfigProvider { } } -impl ConfigProvider for SnapshotConfigProvider +impl NodeConfigSource for SnapshotConfigProvider where M: RegistrationSetMaterializer, { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { let mut registrations = self .registrations .lock() .expect("cfgsync registration store should not be poisoned"); registrations.insert(registration.identifier.clone(), registration); - RegistrationResponse::Registered + RegisterNodeResponse::Registered } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { let registration = match self.registration_for(®istration.identifier) { Some(registration) => registration, None => { - return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -269,45 +269,47 @@ where let catalog = match self.materializer.materialize_snapshot(®istrations) { Ok(Some(catalog)) => catalog, Ok(None) => { - return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( ®istration.identifier, )); } Err(error) => { - return RepoResponse::Error(CfgSyncErrorResponse::internal(format!( + return ConfigResolveResponse::Error(CfgSyncErrorResponse::internal(format!( "failed to materialize config snapshot: {error}" ))); } }; match catalog.resolve(®istration.identifier) { - Some(config) => RepoResponse::Config(CfgSyncPayload::from_files(config.files.clone())), - None => RepoResponse::Error(CfgSyncErrorResponse::missing_config( + Some(config) => { + ConfigResolveResponse::Config(CfgSyncPayload::from_files(config.files.clone())) + } + None => ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )), } } } -impl ConfigProvider for RegistrationConfigProvider +impl NodeConfigSource for RegistrationConfigProvider where M: NodeArtifactsMaterializer, { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { let mut registrations = self .registrations .lock() .expect("cfgsync registration store should not be poisoned"); registrations.insert(registration.identifier.clone(), registration); - RegistrationResponse::Registered + RegisterNodeResponse::Registered } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { let registration = match self.registration_for(®istration.identifier) { Some(registration) => registration, None => { - return RepoResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -315,13 +317,13 @@ where let registrations = self.registration_set(); match self.materializer.materialize(®istration, ®istrations) { - Ok(Some(artifacts)) => { - RepoResponse::Config(CfgSyncPayload::from_files(artifacts.files().to_vec())) - } - Ok(None) => { - RepoResponse::Error(CfgSyncErrorResponse::not_ready(®istration.identifier)) - } - Err(error) => RepoResponse::Error(CfgSyncErrorResponse::internal(format!( + Ok(Some(artifacts)) => ConfigResolveResponse::Config(CfgSyncPayload::from_files( + artifacts.files().to_vec(), + )), + Ok(None) => ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + ®istration.identifier, + )), + Err(error) => ConfigResolveResponse::Error(CfgSyncErrorResponse::internal(format!( "failed to materialize config for host {}: {error}", registration.identifier ))), @@ -481,7 +483,9 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use cfgsync_artifacts::ArtifactFile; - use cfgsync_core::{CfgSyncErrorCode, ConfigProvider, NodeRegistration, RepoResponse}; + use cfgsync_core::{ + CfgSyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, + }; use super::{ ArtifactSet, DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, @@ -512,8 +516,10 @@ mod tests { let _ = provider.register(registration.clone()); match provider.resolve(®istration) { - RepoResponse::Config(payload) => assert_eq!(payload.files()[0].path, "/config.yaml"), - RepoResponse::Error(error) => panic!("expected config, got {error}"), + ConfigResolveResponse::Config(payload) => { + assert_eq!(payload.files()[0].path, "/config.yaml") + } + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } @@ -527,8 +533,10 @@ mod tests { let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); match provider.resolve(®istration) { - RepoResponse::Config(_) => panic!("expected not-ready error"), - RepoResponse::Error(error) => assert!(matches!(error.code, CfgSyncErrorCode::NotReady)), + ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgSyncErrorCode::NotReady)) + } } } @@ -569,18 +577,20 @@ mod tests { let _ = provider.register(node_a.clone()); match provider.resolve(&node_a) { - RepoResponse::Config(_) => panic!("expected not-ready error"), - RepoResponse::Error(error) => assert!(matches!(error.code, CfgSyncErrorCode::NotReady)), + ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgSyncErrorCode::NotReady)) + } } let _ = provider.register(node_b); match provider.resolve(&node_a) { - RepoResponse::Config(payload) => { + ConfigResolveResponse::Config(payload) => { assert_eq!(payload.files()[0].content, "id: node-a"); assert_eq!(payload.files()[1].content, "peers: 2"); } - RepoResponse::Error(error) => panic!("expected config, got {error}"), + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } } diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index e0b3969..e49e20f 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -12,8 +12,15 @@ pub use render::{ render_cfgsync_yaml_from_template, write_rendered_cfgsync, }; pub use repo::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, - ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, NodeRegistration, - RegistrationPayload, RegistrationResponse, RepoResponse, + BundleConfigSource, BundleConfigSourceError, CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, + CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, + NodeRegistration, RegisterNodeResponse, RegistrationPayload, StaticConfigSource, }; -pub use server::{CfgSyncState, RunCfgsyncError, cfgsync_app, run_cfgsync}; +#[doc(hidden)] +pub use repo::{ + ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, RegistrationResponse, + RepoResponse, +}; +#[doc(hidden)] +pub use server::CfgSyncState; +pub use server::{CfgsyncServerState, RunCfgsyncError, cfgsync_app, run_cfgsync}; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index 15d1332..692a8fb 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -199,59 +199,59 @@ impl CfgSyncErrorResponse { } } -/// Repository resolution outcome for a requested node identifier. -pub enum RepoResponse { +/// Resolution outcome for a requested node identifier. +pub enum ConfigResolveResponse { Config(CfgSyncPayload), Error(CfgSyncErrorResponse), } -/// Repository outcome for a node registration request. -pub enum RegistrationResponse { +/// Outcome for a node registration request. +pub enum RegisterNodeResponse { Registered, Error(CfgSyncErrorResponse), } -/// Read-only source for cfgsync node payloads. -pub trait ConfigProvider: Send + Sync { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse; +/// Source of cfgsync node payloads. +pub trait NodeConfigSource: Send + Sync { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse; - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse; + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse; } -/// In-memory map-backed provider used by cfgsync server state. -pub struct ConfigRepo { +/// In-memory map-backed source used by cfgsync server state. +pub struct StaticConfigSource { configs: HashMap, } -impl ConfigRepo { +impl StaticConfigSource { #[must_use] pub fn from_bundle(configs: HashMap) -> Arc { Arc::new(Self { configs }) } } -impl ConfigProvider for ConfigRepo { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { +impl NodeConfigSource for StaticConfigSource { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { if self.configs.contains_key(®istration.identifier) { - RegistrationResponse::Registered + RegisterNodeResponse::Registered } else { - RegistrationResponse::Error(CfgSyncErrorResponse::missing_config( + RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )) } } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { self.configs .get(®istration.identifier) .cloned() .map_or_else( || { - RepoResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )) }, - RepoResponse::Config, + ConfigResolveResponse::Config, ) } } @@ -345,24 +345,24 @@ mod tests { fn resolves_existing_identifier() { let mut configs = HashMap::new(); configs.insert("node-1".to_owned(), sample_payload()); - let repo = ConfigRepo { configs }; + let repo = StaticConfigSource { configs }; match repo.resolve(&NodeRegistration::new( "node-1", "127.0.0.1".parse().expect("parse ip"), )) { - RepoResponse::Config(payload) => { + ConfigResolveResponse::Config(payload) => { assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); assert_eq!(payload.files.len(), 1); assert_eq!(payload.files[0].path, "/config.yaml"); } - RepoResponse::Error(error) => panic!("expected config response, got {error}"), + ConfigResolveResponse::Error(error) => panic!("expected config response, got {error}"), } } #[test] fn reports_missing_identifier() { - let repo = ConfigRepo { + let repo = StaticConfigSource { configs: HashMap::new(), }; @@ -370,8 +370,8 @@ mod tests { "unknown-node", "127.0.0.1".parse().expect("parse ip"), )) { - RepoResponse::Config(_) => panic!("expected missing-config error"), - RepoResponse::Error(error) => { + ConfigResolveResponse::Config(_) => panic!("expected missing-config error"), + ConfigResolveResponse::Error(error) => { assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); assert!(error.message.contains("unknown-node")); } @@ -393,7 +393,7 @@ nodes: .expect("write bundle yaml"); let provider = - FileConfigProvider::from_yaml_file(bundle_file.path()).expect("load file provider"); + BundleConfigSource::from_yaml_file(bundle_file.path()).expect("load file provider"); let _ = provider.register(NodeRegistration::new( "node-1", @@ -404,8 +404,8 @@ nodes: "node-1", "127.0.0.1".parse().expect("parse ip"), )) { - RepoResponse::Config(payload) => assert_eq!(payload.files.len(), 1), - RepoResponse::Error(error) => panic!("expected config, got {error}"), + ConfigResolveResponse::Config(payload) => assert_eq!(payload.files.len(), 1), + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } @@ -413,21 +413,21 @@ nodes: fn resolve_accepts_known_registration_without_gating() { let mut configs = HashMap::new(); configs.insert("node-1".to_owned(), sample_payload()); - let repo = ConfigRepo { configs }; + let repo = StaticConfigSource { configs }; match repo.resolve(&NodeRegistration::new( "node-1", "127.0.0.1".parse().expect("parse ip"), )) { - RepoResponse::Config(_) => {} - RepoResponse::Error(error) => panic!("expected config, got {error}"), + ConfigResolveResponse::Config(_) => {} + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } } -/// Failures when loading a file-backed cfgsync provider. +/// Failures when loading a bundle-backed cfgsync source. #[derive(Debug, Error)] -pub enum FileConfigProviderError { +pub enum BundleConfigSourceError { #[error("failed to read cfgsync bundle at {path}: {source}")] Read { path: String, @@ -442,21 +442,21 @@ pub enum FileConfigProviderError { }, } -/// YAML bundle-backed provider implementation. -pub struct FileConfigProvider { - inner: ConfigRepo, +/// YAML bundle-backed source implementation. +pub struct BundleConfigSource { + inner: StaticConfigSource, } -impl FileConfigProvider { +impl BundleConfigSource { /// Loads provider state from a cfgsync bundle YAML file. - pub fn from_yaml_file(path: &Path) -> Result { - let raw = fs::read_to_string(path).map_err(|source| FileConfigProviderError::Read { + pub fn from_yaml_file(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| BundleConfigSourceError::Read { path: path.display().to_string(), source, })?; let bundle: CfgSyncBundle = - serde_yaml::from_str(&raw).map_err(|source| FileConfigProviderError::Parse { + serde_yaml::from_str(&raw).map_err(|source| BundleConfigSourceError::Parse { path: path.display().to_string(), source, })?; @@ -468,17 +468,17 @@ impl FileConfigProvider { .collect(); Ok(Self { - inner: ConfigRepo { configs }, + inner: StaticConfigSource { configs }, }) } } -impl ConfigProvider for FileConfigProvider { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { +impl NodeConfigSource for BundleConfigSource { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { self.inner.register(registration) } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { self.inner.resolve(registration) } } @@ -486,3 +486,23 @@ impl ConfigProvider for FileConfigProvider { fn payload_from_bundle_node(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { (node.identifier, CfgSyncPayload::from_files(node.files)) } + +#[doc(hidden)] +pub type RepoResponse = ConfigResolveResponse; + +#[doc(hidden)] +pub type RegistrationResponse = RegisterNodeResponse; + +#[doc(hidden)] +pub trait ConfigProvider: NodeConfigSource {} + +impl ConfigProvider for T {} + +#[doc(hidden)] +pub type ConfigRepo = StaticConfigSource; + +#[doc(hidden)] +pub type FileConfigProvider = BundleConfigSource; + +#[doc(hidden)] +pub type FileConfigProviderError = BundleConfigSourceError; diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 794af79..6ac57ab 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -4,17 +4,18 @@ use axum::{Json, Router, extract::State, http::StatusCode, response::IntoRespons use thiserror::Error; use crate::repo::{ - CfgSyncErrorCode, ConfigProvider, NodeRegistration, RegistrationResponse, RepoResponse, + CfgSyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, + RegisterNodeResponse, }; /// Runtime state shared across cfgsync HTTP handlers. -pub struct CfgSyncState { - repo: Arc, +pub struct CfgsyncServerState { + repo: Arc, } -impl CfgSyncState { +impl CfgsyncServerState { #[must_use] - pub fn new(repo: Arc) -> Self { + pub fn new(repo: Arc) -> Self { Self { repo } } } @@ -36,14 +37,16 @@ pub enum RunCfgsyncError { } async fn node_config( - State(state): State>, + State(state): State>, Json(payload): Json, ) -> impl IntoResponse { let response = resolve_node_config_response(&state, &payload); match response { - RepoResponse::Config(payload_data) => (StatusCode::OK, Json(payload_data)).into_response(), - RepoResponse::Error(error) => { + ConfigResolveResponse::Config(payload_data) => { + (StatusCode::OK, Json(payload_data)).into_response() + } + ConfigResolveResponse::Error(error) => { let status = error_status(&error.code); (status, Json(error)).into_response() @@ -52,12 +55,12 @@ async fn node_config( } async fn register_node( - State(state): State>, + State(state): State>, Json(payload): Json, ) -> impl IntoResponse { match state.repo.register(payload) { - RegistrationResponse::Registered => StatusCode::ACCEPTED.into_response(), - RegistrationResponse::Error(error) => { + RegisterNodeResponse::Registered => StatusCode::ACCEPTED.into_response(), + RegisterNodeResponse::Error(error) => { let status = error_status(&error.code); (status, Json(error)).into_response() @@ -66,9 +69,9 @@ async fn register_node( } fn resolve_node_config_response( - state: &CfgSyncState, + state: &CfgsyncServerState, registration: &NodeRegistration, -) -> RepoResponse { +) -> ConfigResolveResponse { state.repo.resolve(registration) } @@ -80,7 +83,7 @@ fn error_status(code: &CfgSyncErrorCode) -> StatusCode { } } -pub fn cfgsync_app(state: CfgSyncState) -> Router { +pub fn cfgsync_app(state: CfgsyncServerState) -> Router { Router::new() .route("/register", post(register_node)) .route("/node", post(node_config)) @@ -89,7 +92,7 @@ pub fn cfgsync_app(state: CfgSyncState) -> Router { } /// Runs cfgsync HTTP server on the provided port until shutdown/error. -pub async fn run_cfgsync(port: u16, state: CfgSyncState) -> Result<(), RunCfgsyncError> { +pub async fn run_cfgsync(port: u16, state: CfgsyncServerState) -> Result<(), RunCfgsyncError> { let app = cfgsync_app(state); println!("Server running on http://0.0.0.0:{port}"); @@ -105,44 +108,47 @@ pub async fn run_cfgsync(port: u16, state: CfgSyncState) -> Result<(), RunCfgsyn Ok(()) } +#[doc(hidden)] +pub type CfgSyncState = CfgsyncServerState; + #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; - use super::{CfgSyncState, NodeRegistration, node_config, register_node}; + use super::{CfgsyncServerState, NodeRegistration, node_config, register_node}; use crate::repo::{ CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, - CfgSyncPayload, ConfigProvider, RegistrationResponse, RepoResponse, + CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, RegisterNodeResponse, }; struct StaticProvider { data: HashMap, } - impl ConfigProvider for StaticProvider { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + impl NodeConfigSource for StaticProvider { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { if self.data.contains_key(®istration.identifier) { - RegistrationResponse::Registered + RegisterNodeResponse::Registered } else { - RegistrationResponse::Error(CfgSyncErrorResponse::missing_config( + RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )) } } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { self.data .get(®istration.identifier) .cloned() .map_or_else( || { - RepoResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )) }, - RepoResponse::Config, + ConfigResolveResponse::Config, ) } } @@ -152,10 +158,10 @@ mod tests { registrations: std::sync::Mutex>, } - impl ConfigProvider for RegistrationAwareProvider { - fn register(&self, registration: NodeRegistration) -> RegistrationResponse { + impl NodeConfigSource for RegistrationAwareProvider { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { if !self.data.contains_key(®istration.identifier) { - return RegistrationResponse::Error(CfgSyncErrorResponse::missing_config( + return RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )); } @@ -166,17 +172,17 @@ mod tests { .expect("test registration store should not be poisoned"); registrations.insert(registration.identifier.clone(), registration); - RegistrationResponse::Registered + RegisterNodeResponse::Registered } - fn resolve(&self, registration: &NodeRegistration) -> RepoResponse { + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { 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( + return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -186,11 +192,11 @@ mod tests { .cloned() .map_or_else( || { - RepoResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )) }, - RepoResponse::Config, + ConfigResolveResponse::Config, ) } } @@ -211,7 +217,7 @@ mod tests { data, registrations: std::sync::Mutex::new(HashMap::new()), }); - let state = Arc::new(CfgSyncState::new(provider)); + let state = Arc::new(CfgsyncServerState::new(provider)); let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); let _ = register_node(State(state.clone()), Json(payload.clone())) @@ -230,7 +236,7 @@ mod tests { let provider = Arc::new(StaticProvider { data: HashMap::new(), }); - let state = Arc::new(CfgSyncState::new(provider)); + let state = Arc::new(CfgsyncServerState::new(provider)); let payload = NodeRegistration::new("missing-node", "127.0.0.1".parse().expect("valid ip")); let response = node_config(State(state), Json(payload)) @@ -256,7 +262,7 @@ mod tests { data, registrations: std::sync::Mutex::new(HashMap::new()), }); - let state = Arc::new(CfgSyncState::new(provider)); + let state = Arc::new(CfgsyncServerState::new(provider)); let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); let response = node_config(State(state), Json(payload)) diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 00af686..1fb9efe 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -181,7 +181,8 @@ mod tests { use std::collections::HashMap; use cfgsync_core::{ - CfgSyncBundle, CfgSyncBundleNode, CfgSyncPayload, CfgSyncState, ConfigRepo, run_cfgsync, + CfgSyncBundle, CfgSyncBundleNode, CfgSyncPayload, CfgsyncServerState, StaticConfigSource, + run_cfgsync, }; use tempfile::tempdir; @@ -201,8 +202,8 @@ mod tests { ], }]); - let repo = ConfigRepo::from_bundle(bundle_to_payload_map(bundle)); - let state = CfgSyncState::new(repo); + let repo = StaticConfigSource::from_bundle(bundle_to_payload_map(bundle)); + let state = CfgsyncServerState::new(repo); let port = allocate_test_port(); let address = format!("http://127.0.0.1:{port}"); let server = tokio::spawn(async move { diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 16927d2..9ec5a6d 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -2,7 +2,9 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; use cfgsync_adapter::{NodeArtifacts, NodeArtifactsCatalog, RegistrationConfigProvider}; -use cfgsync_core::{CfgSyncBundle, CfgSyncState, ConfigProvider, FileConfigProvider, run_cfgsync}; +use cfgsync_core::{ + BundleConfigSource, CfgSyncBundle, CfgsyncServerState, NodeConfigSource, run_cfgsync, +}; use serde::Deserialize; /// Runtime cfgsync server config loaded from YAML. @@ -25,14 +27,14 @@ impl CfgSyncServerConfig { } } -fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { - let provider = FileConfigProvider::from_yaml_file(bundle_path) +fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { + let provider = BundleConfigSource::from_yaml_file(bundle_path) .with_context(|| format!("loading cfgsync provider from {}", bundle_path.display()))?; Ok(Arc::new(provider)) } -fn load_materializing_provider(bundle_path: &Path) -> anyhow::Result> { +fn load_materializing_provider(bundle_path: &Path) -> anyhow::Result> { let bundle = load_bundle_yaml(bundle_path)?; let catalog = build_node_catalog(bundle); let provider = RegistrationConfigProvider::new(catalog); @@ -87,12 +89,12 @@ pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { fn build_server_state( config: &CfgSyncServerConfig, bundle_path: &Path, -) -> anyhow::Result { +) -> anyhow::Result { let repo = if config.registration_flow { load_materializing_provider(bundle_path)? } else { load_bundle_provider(bundle_path)? }; - Ok(CfgSyncState::new(repo)) + Ok(CfgsyncServerState::new(repo)) } From 5b69519ab136cfed3c072eabbe1368693192c84b Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 11:06:16 +0100 Subject: [PATCH 11/38] Clarify cfgsync runtime serving modes --- cfgsync/runtime/src/lib.rs | 6 +- cfgsync/runtime/src/server.rs | 110 +++++++++++++++++++++++---- logos/runtime/ext/src/cfgsync/mod.rs | 4 +- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index 7c0a75b..f9b225f 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -4,4 +4,8 @@ mod client; mod server; pub use client::run_cfgsync_client_from_env; -pub use server::{CfgSyncServerConfig, run_cfgsync_server}; +#[doc(hidden)] +pub use server::CfgSyncServerConfig; +pub use server::{ + CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, run_cfgsync_server, +}; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 9ec5a6d..1090ac0 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -6,24 +6,102 @@ use cfgsync_core::{ BundleConfigSource, CfgSyncBundle, CfgsyncServerState, NodeConfigSource, run_cfgsync, }; use serde::Deserialize; +use thiserror::Error; /// Runtime cfgsync server config loaded from YAML. #[derive(Debug, Deserialize, Clone)] -pub struct CfgSyncServerConfig { +pub struct CfgsyncServerConfig { pub port: u16, pub bundle_path: String, #[serde(default)] - pub registration_flow: bool, + pub serving_mode: CfgsyncServingMode, } -impl CfgSyncServerConfig { - /// Loads cfgsync runtime server config from a YAML file. - pub fn load_from_file(path: &Path) -> anyhow::Result { - let config_content = fs::read_to_string(path) - .with_context(|| format!("failed to read cfgsync config file {}", path.display()))?; +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum CfgsyncServingMode { + #[default] + Bundle, + Registration, +} - serde_yaml::from_str(&config_content) - .with_context(|| format!("failed to parse cfgsync config file {}", path.display())) +#[derive(Debug, Deserialize)] +struct RawCfgsyncServerConfig { + port: u16, + bundle_path: String, + #[serde(default)] + serving_mode: Option, + #[serde(default)] + registration_flow: Option, +} + +#[derive(Debug, Error)] +pub enum LoadCfgsyncServerConfigError { + #[error("failed to read cfgsync config file {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse cfgsync config file {path}: {source}")] + Parse { + path: String, + #[source] + source: serde_yaml::Error, + }, +} + +impl CfgsyncServerConfig { + /// Loads cfgsync runtime server config from a YAML file. + pub fn load_from_file(path: &Path) -> Result { + let config_path = path.display().to_string(); + let config_content = + fs::read_to_string(path).map_err(|source| LoadCfgsyncServerConfigError::Read { + path: config_path.clone(), + source, + })?; + + let raw: RawCfgsyncServerConfig = + serde_yaml::from_str(&config_content).map_err(|source| { + LoadCfgsyncServerConfigError::Parse { + path: config_path, + source, + } + })?; + + Ok(Self { + port: raw.port, + bundle_path: raw.bundle_path, + serving_mode: raw + .serving_mode + .unwrap_or_else(|| mode_from_legacy_registration_flow(raw.registration_flow)), + }) + } + + #[must_use] + pub fn for_bundle(port: u16, bundle_path: impl Into) -> Self { + Self { + port, + bundle_path: bundle_path.into(), + serving_mode: CfgsyncServingMode::Bundle, + } + } + + #[must_use] + pub fn for_registration(port: u16, bundle_path: impl Into) -> Self { + Self { + port, + bundle_path: bundle_path.into(), + serving_mode: CfgsyncServingMode::Registration, + } + } +} + +fn mode_from_legacy_registration_flow(registration_flow: Option) -> CfgsyncServingMode { + if registration_flow.unwrap_or(false) { + CfgsyncServingMode::Registration + } else { + CfgsyncServingMode::Bundle } } @@ -77,7 +155,7 @@ fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::Path /// Loads runtime config and starts cfgsync HTTP server process. pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { - let config = CfgSyncServerConfig::load_from_file(config_path)?; + let config = CfgsyncServerConfig::load_from_file(config_path)?; let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); let state = build_server_state(&config, &bundle_path)?; @@ -87,14 +165,16 @@ pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { } fn build_server_state( - config: &CfgSyncServerConfig, + config: &CfgsyncServerConfig, bundle_path: &Path, ) -> anyhow::Result { - let repo = if config.registration_flow { - load_materializing_provider(bundle_path)? - } else { - load_bundle_provider(bundle_path)? + let repo = match config.serving_mode { + CfgsyncServingMode::Bundle => load_bundle_provider(bundle_path)?, + CfgsyncServingMode::Registration => load_materializing_provider(bundle_path)?, }; Ok(CfgsyncServerState::new(repo)) } + +#[doc(hidden)] +pub type CfgSyncServerConfig = CfgsyncServerConfig; diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index f607a9c..6bc28cc 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -122,8 +122,8 @@ fn build_cfgsync_server_config() -> Value { ); root.insert( - Value::String("registration_flow".to_string()), - Value::Bool(true), + Value::String("serving_mode".to_string()), + Value::String("registration".to_string()), ); Value::Mapping(root) From 9ebf029f2a54e2b5ec006e14050bc4769a2aab0a Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 11:08:17 +0100 Subject: [PATCH 12/38] Rename cfgsync artifact surface for external use --- cfgsync/adapter/src/lib.rs | 10 +++--- cfgsync/core/src/bundle.rs | 20 +++++++----- cfgsync/core/src/client.rs | 8 ++--- cfgsync/core/src/lib.rs | 11 ++++--- cfgsync/core/src/repo.rs | 47 +++++++++++++++++----------- cfgsync/core/src/server.rs | 14 ++++----- cfgsync/runtime/src/client.rs | 36 ++++++++++++--------- cfgsync/runtime/src/server.rs | 6 ++-- logos/runtime/ext/src/cfgsync/mod.rs | 18 +++++------ 9 files changed, 97 insertions(+), 73 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 6df51e9..caad421 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, error::Error, sync::Mutex}; use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{ - CfgSyncErrorResponse, CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, + CfgSyncErrorResponse, ConfigResolveResponse, NodeArtifactsPayload, NodeConfigSource, NodeRegistration, RegisterNodeResponse, }; use serde::{Deserialize, Serialize}; @@ -281,9 +281,9 @@ where }; match catalog.resolve(®istration.identifier) { - Some(config) => { - ConfigResolveResponse::Config(CfgSyncPayload::from_files(config.files.clone())) - } + Some(config) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( + config.files.clone(), + )), None => ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( ®istration.identifier, )), @@ -317,7 +317,7 @@ where let registrations = self.registration_set(); match self.materializer.materialize(®istration, ®istrations) { - Ok(Some(artifacts)) => ConfigResolveResponse::Config(CfgSyncPayload::from_files( + Ok(Some(artifacts)) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( artifacts.files().to_vec(), )), Ok(None) => ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( diff --git a/cfgsync/core/src/bundle.rs b/cfgsync/core/src/bundle.rs index 003dd8a..d8f6cab 100644 --- a/cfgsync/core/src/bundle.rs +++ b/cfgsync/core/src/bundle.rs @@ -1,26 +1,32 @@ use serde::{Deserialize, Serialize}; -use crate::CfgSyncFile; +use crate::NodeArtifactFile; /// Top-level cfgsync bundle containing per-node file payloads. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncBundle { - pub nodes: Vec, +pub struct NodeArtifactsBundle { + pub nodes: Vec, } -impl CfgSyncBundle { +impl NodeArtifactsBundle { #[must_use] - pub fn new(nodes: Vec) -> Self { + pub fn new(nodes: Vec) -> Self { Self { nodes } } } /// Artifact set for a single node resolved by identifier. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncBundleNode { +pub struct NodeArtifactsBundleEntry { /// Stable node identifier used by cfgsync lookup. pub identifier: String, /// Files that should be materialized for the node. #[serde(default)] - pub files: Vec, + pub files: Vec, } + +#[doc(hidden)] +pub type CfgSyncBundle = NodeArtifactsBundle; + +#[doc(hidden)] +pub type CfgSyncBundleNode = NodeArtifactsBundleEntry; diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index bfc07ec..545d9a2 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -1,7 +1,7 @@ use serde::Serialize; use thiserror::Error; -use crate::repo::{CfgSyncErrorResponse, CfgSyncPayload, NodeRegistration}; +use crate::repo::{CfgSyncErrorResponse, NodeArtifactsPayload, NodeRegistration}; /// cfgsync client-side request/response failures. #[derive(Debug, Error)] @@ -58,7 +58,7 @@ impl CfgSyncClient { pub async fn fetch_node_config( &self, payload: &NodeRegistration, - ) -> Result { + ) -> Result { self.post_json("/node", payload).await } @@ -66,7 +66,7 @@ impl CfgSyncClient { pub async fn fetch_init_with_node_config( &self, payload: &NodeRegistration, - ) -> Result { + ) -> Result { self.post_json("/init-with-node", payload).await } @@ -93,7 +93,7 @@ impl CfgSyncClient { &self, path: &str, payload: &P, - ) -> Result { + ) -> Result { let url = self.endpoint_url(path); let response = self.http.post(url).json(payload).send().await?; diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index e49e20f..75ed135 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -4,7 +4,9 @@ pub mod render; pub mod repo; pub mod server; +#[doc(hidden)] pub use bundle::{CfgSyncBundle, CfgSyncBundleNode}; +pub use bundle::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; pub use client::{CfgSyncClient, ClientError, ConfigFetchStatus}; pub use render::{ CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, @@ -13,13 +15,14 @@ pub use render::{ }; pub use repo::{ BundleConfigSource, BundleConfigSourceError, CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, - CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, - NodeRegistration, RegisterNodeResponse, RegistrationPayload, StaticConfigSource, + CfgSyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, + NodeConfigSource, NodeRegistration, RegisterNodeResponse, RegistrationPayload, + StaticConfigSource, }; #[doc(hidden)] pub use repo::{ - ConfigProvider, ConfigRepo, FileConfigProvider, FileConfigProviderError, RegistrationResponse, - RepoResponse, + CfgSyncFile, CfgSyncPayload, ConfigProvider, ConfigRepo, FileConfigProvider, + FileConfigProviderError, RegistrationResponse, RepoResponse, }; #[doc(hidden)] pub use server::CfgSyncState; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index 692a8fb..b1581f7 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -5,22 +5,22 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwn use serde_json::Value; use thiserror::Error; -use crate::{CfgSyncBundle, CfgSyncBundleNode}; +use crate::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; /// Schema version served by cfgsync payload responses. pub const CFGSYNC_SCHEMA_VERSION: u16 = 1; /// Canonical cfgsync file type used in payloads and bundles. -pub type CfgSyncFile = ArtifactFile; +pub type NodeArtifactFile = ArtifactFile; /// Payload returned by cfgsync server for one node. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CfgSyncPayload { +pub struct NodeArtifactsPayload { /// Payload schema version for compatibility checks. pub schema_version: u16, /// Files that must be written on the target node. #[serde(default)] - pub files: Vec, + pub files: Vec, } /// Adapter-owned registration payload stored alongside a generic node identity. @@ -137,9 +137,9 @@ impl NodeRegistration { } } -impl CfgSyncPayload { +impl NodeArtifactsPayload { #[must_use] - pub fn from_files(files: Vec) -> Self { + pub fn from_files(files: Vec) -> Self { Self { schema_version: CFGSYNC_SCHEMA_VERSION, files, @@ -147,7 +147,7 @@ impl CfgSyncPayload { } #[must_use] - pub fn files(&self) -> &[CfgSyncFile] { + pub fn files(&self) -> &[NodeArtifactFile] { &self.files } @@ -201,7 +201,7 @@ impl CfgSyncErrorResponse { /// Resolution outcome for a requested node identifier. pub enum ConfigResolveResponse { - Config(CfgSyncPayload), + Config(NodeArtifactsPayload), Error(CfgSyncErrorResponse), } @@ -220,12 +220,12 @@ pub trait NodeConfigSource: Send + Sync { /// In-memory map-backed source used by cfgsync server state. pub struct StaticConfigSource { - configs: HashMap, + configs: HashMap, } impl StaticConfigSource { #[must_use] - pub fn from_bundle(configs: HashMap) -> Arc { + pub fn from_bundle(configs: HashMap) -> Arc { Arc::new(Self { configs }) } } @@ -273,19 +273,19 @@ pub enum BundleLoadError { } #[must_use] -pub fn bundle_to_payload_map(bundle: CfgSyncBundle) -> HashMap { +pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { bundle .nodes .into_iter() .map(|node| { - let CfgSyncBundleNode { identifier, files } = node; + let NodeArtifactsBundleEntry { identifier, files } = node; - (identifier, CfgSyncPayload::from_files(files)) + (identifier, NodeArtifactsPayload::from_files(files)) }) .collect() } -pub fn load_bundle(path: &Path) -> Result { +pub fn load_bundle(path: &Path) -> Result { let path_string = path.display().to_string(); let raw = fs::read_to_string(path).map_err(|source| BundleLoadError::ReadBundle { path: path_string.clone(), @@ -337,8 +337,8 @@ mod tests { assert_eq!(typed.service, "blend"); } - fn sample_payload() -> CfgSyncPayload { - CfgSyncPayload::from_files(vec![CfgSyncFile::new("/config.yaml", "key: value")]) + fn sample_payload() -> NodeArtifactsPayload { + NodeArtifactsPayload::from_files(vec![NodeArtifactFile::new("/config.yaml", "key: value")]) } #[test] @@ -455,7 +455,7 @@ impl BundleConfigSource { source, })?; - let bundle: CfgSyncBundle = + let bundle: NodeArtifactsBundle = serde_yaml::from_str(&raw).map_err(|source| BundleConfigSourceError::Parse { path: path.display().to_string(), source, @@ -483,8 +483,11 @@ impl NodeConfigSource for BundleConfigSource { } } -fn payload_from_bundle_node(node: CfgSyncBundleNode) -> (String, CfgSyncPayload) { - (node.identifier, CfgSyncPayload::from_files(node.files)) +fn payload_from_bundle_node(node: NodeArtifactsBundleEntry) -> (String, NodeArtifactsPayload) { + ( + node.identifier, + NodeArtifactsPayload::from_files(node.files), + ) } #[doc(hidden)] @@ -506,3 +509,9 @@ pub type FileConfigProvider = BundleConfigSource; #[doc(hidden)] pub type FileConfigProviderError = BundleConfigSourceError; + +#[doc(hidden)] +pub type CfgSyncFile = NodeArtifactFile; + +#[doc(hidden)] +pub type CfgSyncPayload = NodeArtifactsPayload; diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 6ac57ab..2ab2da7 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -119,12 +119,12 @@ mod tests { use super::{CfgsyncServerState, NodeRegistration, node_config, register_node}; use crate::repo::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, - CfgSyncPayload, ConfigResolveResponse, NodeConfigSource, RegisterNodeResponse, + CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, ConfigResolveResponse, + NodeArtifactFile, NodeArtifactsPayload, NodeConfigSource, RegisterNodeResponse, }; struct StaticProvider { - data: HashMap, + data: HashMap, } impl NodeConfigSource for StaticProvider { @@ -154,7 +154,7 @@ mod tests { } struct RegistrationAwareProvider { - data: HashMap, + data: HashMap, registrations: std::sync::Mutex>, } @@ -201,10 +201,10 @@ mod tests { } } - fn sample_payload() -> CfgSyncPayload { - CfgSyncPayload { + fn sample_payload() -> NodeArtifactsPayload { + NodeArtifactsPayload { schema_version: CFGSYNC_SCHEMA_VERSION, - files: vec![CfgSyncFile::new("/app-config.yaml", "app: test")], + files: vec![NodeArtifactFile::new("/app-config.yaml", "app: test")], } } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 1fb9efe..d0bea51 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -6,8 +6,8 @@ use std::{ use anyhow::{Context as _, Result, bail}; use cfgsync_core::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncClient, CfgSyncFile, CfgSyncPayload, NodeRegistration, - RegistrationPayload, + CFGSYNC_SCHEMA_VERSION, CfgSyncClient, NodeArtifactFile, NodeArtifactsPayload, + NodeRegistration, RegistrationPayload, }; use thiserror::Error; use tokio::time::{Duration, sleep}; @@ -22,7 +22,10 @@ enum ClientEnvError { InvalidIp { value: String }, } -async fn fetch_with_retry(payload: &NodeRegistration, server_addr: &str) -> Result { +async fn fetch_with_retry( + payload: &NodeRegistration, + server_addr: &str, +) -> Result { let client = CfgSyncClient::new(server_addr); for attempt in 1..=FETCH_ATTEMPTS { @@ -43,7 +46,10 @@ async fn fetch_with_retry(payload: &NodeRegistration, server_addr: &str) -> Resu unreachable!("cfgsync fetch loop always returns before exhausting attempts"); } -async fn fetch_once(client: &CfgSyncClient, payload: &NodeRegistration) -> Result { +async fn fetch_once( + client: &CfgSyncClient, + payload: &NodeRegistration, +) -> Result { let response = client.fetch_node_config(payload).await?; Ok(response) @@ -92,7 +98,7 @@ async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result< unreachable!("cfgsync register loop always returns before exhausting attempts"); } -fn ensure_schema_version(config: &CfgSyncPayload) -> Result<()> { +fn ensure_schema_version(config: &NodeArtifactsPayload) -> Result<()> { if config.schema_version != CFGSYNC_SCHEMA_VERSION { bail!( "unsupported cfgsync payload schema version {}, expected {}", @@ -104,7 +110,7 @@ fn ensure_schema_version(config: &CfgSyncPayload) -> Result<()> { Ok(()) } -fn collect_payload_files(config: &CfgSyncPayload) -> Result<&[CfgSyncFile]> { +fn collect_payload_files(config: &NodeArtifactsPayload) -> Result<&[NodeArtifactFile]> { if config.is_empty() { bail!("cfgsync payload contains no files"); } @@ -112,7 +118,7 @@ fn collect_payload_files(config: &CfgSyncPayload) -> Result<&[CfgSyncFile]> { Ok(config.files()) } -fn write_cfgsync_file(file: &CfgSyncFile) -> Result<()> { +fn write_cfgsync_file(file: &NodeArtifactFile) -> Result<()> { let path = PathBuf::from(&file.path); ensure_parent_dir(&path)?; @@ -181,8 +187,8 @@ mod tests { use std::collections::HashMap; use cfgsync_core::{ - CfgSyncBundle, CfgSyncBundleNode, CfgSyncPayload, CfgsyncServerState, StaticConfigSource, - run_cfgsync, + CfgsyncServerState, NodeArtifactsBundle, NodeArtifactsBundleEntry, NodeArtifactsPayload, + StaticConfigSource, run_cfgsync, }; use tempfile::tempdir; @@ -194,11 +200,11 @@ mod tests { let app_config_path = dir.path().join("config.yaml"); let deployment_path = dir.path().join("deployment.yaml"); - let bundle = CfgSyncBundle::new(vec![CfgSyncBundleNode { + let bundle = NodeArtifactsBundle::new(vec![NodeArtifactsBundleEntry { identifier: "node-1".to_owned(), files: vec![ - CfgSyncFile::new(app_config_path.to_string_lossy(), "app_key: app_value"), - CfgSyncFile::new(deployment_path.to_string_lossy(), "mode: local"), + NodeArtifactFile::new(app_config_path.to_string_lossy(), "app_key: app_value"), + NodeArtifactFile::new(deployment_path.to_string_lossy(), "mode: local"), ], }]); @@ -227,14 +233,14 @@ mod tests { assert_eq!(deployment, "mode: local"); } - fn bundle_to_payload_map(bundle: CfgSyncBundle) -> HashMap { + fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { bundle .nodes .into_iter() .map(|node| { - let CfgSyncBundleNode { identifier, files } = node; + let NodeArtifactsBundleEntry { identifier, files } = node; - (identifier, CfgSyncPayload::from_files(files)) + (identifier, NodeArtifactsPayload::from_files(files)) }) .collect() } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 1090ac0..00f114c 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -3,7 +3,7 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; use cfgsync_adapter::{NodeArtifacts, NodeArtifactsCatalog, RegistrationConfigProvider}; use cfgsync_core::{ - BundleConfigSource, CfgSyncBundle, CfgsyncServerState, NodeConfigSource, run_cfgsync, + BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, run_cfgsync, }; use serde::Deserialize; use thiserror::Error; @@ -120,7 +120,7 @@ fn load_materializing_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result { +fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { let raw = fs::read_to_string(bundle_path) .with_context(|| format!("reading cfgsync bundle from {}", bundle_path.display()))?; @@ -128,7 +128,7 @@ fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { .with_context(|| format!("parsing cfgsync bundle from {}", bundle_path.display())) } -fn build_node_catalog(bundle: CfgSyncBundle) -> NodeArtifactsCatalog { +fn build_node_catalog(bundle: NodeArtifactsBundle) -> NodeArtifactsCatalog { let nodes = bundle .nodes .into_iter() diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 6bc28cc..4d06cd6 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use cfgsync_adapter::{CfgsyncEnv, build_cfgsync_node_catalog}; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::{ - CfgSyncBundle, CfgSyncBundleNode, + NodeArtifactsBundle, NodeArtifactsBundleEntry, render::{ CfgsyncConfigOverrides, RenderedCfgsync, ensure_bundle_path, render_cfgsync_yaml_from_template, write_rendered_cfgsync, @@ -48,20 +48,20 @@ pub(crate) fn render_cfgsync_from_template( fn build_cfgsync_bundle( topology: &E::Deployment, hostnames: &[String], -) -> Result { +) -> Result { let nodes = build_cfgsync_node_catalog::(topology, hostnames)?.into_configs(); let nodes = nodes .into_iter() - .map(|node| CfgSyncBundleNode { + .map(|node| NodeArtifactsBundleEntry { identifier: node.identifier, files: node.files, }) .collect(); - Ok(CfgSyncBundle::new(nodes)) + Ok(NodeArtifactsBundle::new(nodes)) } -fn append_deployment_files(bundle: &mut CfgSyncBundle) -> Result<()> { +fn append_deployment_files(bundle: &mut NodeArtifactsBundle) -> Result<()> { for node in &mut bundle.nodes { if has_file_path(node, "/deployment.yaml") { continue; @@ -80,18 +80,18 @@ fn append_deployment_files(bundle: &mut CfgSyncBundle) -> Result<()> { Ok(()) } -fn has_file_path(node: &CfgSyncBundleNode, path: &str) -> bool { +fn has_file_path(node: &NodeArtifactsBundleEntry, path: &str) -> bool { node.files.iter().any(|file| file.path == path) } -fn config_file_content(node: &CfgSyncBundleNode) -> Option { +fn config_file_content(node: &NodeArtifactsBundleEntry) -> Option { node.files .iter() .find_map(|file| (file.path == "/config.yaml").then_some(file.content.clone())) } -fn build_bundle_file(path: &str, content: String) -> cfgsync_core::CfgSyncFile { - cfgsync_core::CfgSyncFile { +fn build_bundle_file(path: &str, content: String) -> cfgsync_core::NodeArtifactFile { + cfgsync_core::NodeArtifactFile { path: path.to_owned(), content, } From ef1d7663c5c7a41ebb4ba7a1e6e03aed2e1506c3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 11:12:01 +0100 Subject: [PATCH 13/38] Rename cfgsync client and server surface --- cfgsync/adapter/src/lib.rs | 22 ++++++++++----------- cfgsync/core/src/client.rs | 29 +++++++++++++++++---------- cfgsync/core/src/lib.rs | 16 +++++++++------ cfgsync/core/src/repo.rs | 30 ++++++++++++++++------------ cfgsync/core/src/server.rs | 37 ++++++++++++++++++++--------------- cfgsync/runtime/src/client.rs | 14 +++++++------ cfgsync/runtime/src/server.rs | 4 ++-- 7 files changed, 89 insertions(+), 63 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index caad421..aee1864 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, error::Error, sync::Mutex}; use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{ - CfgSyncErrorResponse, ConfigResolveResponse, NodeArtifactsPayload, NodeConfigSource, + CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactsPayload, NodeConfigSource, NodeRegistration, RegisterNodeResponse, }; use serde::{Deserialize, Serialize}; @@ -259,7 +259,7 @@ where let registration = match self.registration_for(®istration.identifier) { Some(registration) => registration, None => { - return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -269,12 +269,12 @@ where let catalog = match self.materializer.materialize_snapshot(®istrations) { Ok(Some(catalog)) => catalog, Ok(None) => { - return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )); } Err(error) => { - return ConfigResolveResponse::Error(CfgSyncErrorResponse::internal(format!( + return ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( "failed to materialize config snapshot: {error}" ))); } @@ -284,7 +284,7 @@ where Some(config) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( config.files.clone(), )), - None => ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( + None => ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )), } @@ -309,7 +309,7 @@ where let registration = match self.registration_for(®istration.identifier) { Some(registration) => registration, None => { - return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -320,10 +320,10 @@ where Ok(Some(artifacts)) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( artifacts.files().to_vec(), )), - Ok(None) => ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + Ok(None) => ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )), - Err(error) => ConfigResolveResponse::Error(CfgSyncErrorResponse::internal(format!( + Err(error) => ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( "failed to materialize config for host {}: {error}", registration.identifier ))), @@ -484,7 +484,7 @@ mod tests { use cfgsync_artifacts::ArtifactFile; use cfgsync_core::{ - CfgSyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, + CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, }; use super::{ @@ -535,7 +535,7 @@ mod tests { match provider.resolve(®istration) { ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgSyncErrorCode::NotReady)) + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) } } } @@ -579,7 +579,7 @@ mod tests { match provider.resolve(&node_a) { ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgSyncErrorCode::NotReady)) + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) } } diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index 545d9a2..e599c35 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -1,7 +1,7 @@ use serde::Serialize; use thiserror::Error; -use crate::repo::{CfgSyncErrorResponse, NodeArtifactsPayload, NodeRegistration}; +use crate::repo::{CfgsyncErrorCode, CfgsyncErrorResponse, NodeArtifactsPayload, NodeRegistration}; /// cfgsync client-side request/response failures. #[derive(Debug, Error)] @@ -12,7 +12,7 @@ pub enum ClientError { Status { status: reqwest::StatusCode, message: String, - error: Option, + error: Option, }, #[error("failed to parse cfgsync response: {0}")] Decode(serde_json::Error), @@ -22,16 +22,17 @@ pub enum ClientError { pub enum ConfigFetchStatus { Ready, NotReady, + Missing, } /// Reusable HTTP client for cfgsync server endpoints. #[derive(Clone, Debug)] -pub struct CfgSyncClient { +pub struct CfgsyncClient { base_url: String, http: reqwest::Client, } -impl CfgSyncClient { +impl CfgsyncClient { #[must_use] pub fn new(base_url: impl Into) -> Self { let mut base_url = base_url.into(); @@ -80,10 +81,15 @@ impl CfgSyncClient { status, error: Some(error), .. - }) if status == reqwest::StatusCode::TOO_EARLY => { - let _ = error; - Ok(ConfigFetchStatus::NotReady) - } + }) => match error.code { + CfgsyncErrorCode::NotReady => Ok(ConfigFetchStatus::NotReady), + CfgsyncErrorCode::MissingConfig => Ok(ConfigFetchStatus::Missing), + CfgsyncErrorCode::Internal => Err(ClientError::Status { + status, + message: error.message.clone(), + error: Some(error), + }), + }, Err(error) => Err(error), } } @@ -100,7 +106,7 @@ impl CfgSyncClient { let status = response.status(); let body = response.text().await?; if !status.is_success() { - let error = serde_json::from_str::(&body).ok(); + let error = serde_json::from_str::(&body).ok(); let message = error .as_ref() .map(|err| err.message.clone()) @@ -126,7 +132,7 @@ impl CfgSyncClient { let status = response.status(); let body = response.text().await?; if !status.is_success() { - let error = serde_json::from_str::(&body).ok(); + let error = serde_json::from_str::(&body).ok(); let message = error .as_ref() .map(|err| err.message.clone()) @@ -149,3 +155,6 @@ impl CfgSyncClient { } } } + +#[doc(hidden)] +pub type CfgSyncClient = CfgsyncClient; diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index 75ed135..aaac4c7 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -7,23 +7,27 @@ pub mod server; #[doc(hidden)] pub use bundle::{CfgSyncBundle, CfgSyncBundleNode}; pub use bundle::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; -pub use client::{CfgSyncClient, ClientError, ConfigFetchStatus}; +#[doc(hidden)] +pub use client::CfgSyncClient; +pub use client::{CfgsyncClient, ClientError, ConfigFetchStatus}; pub use render::{ CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, apply_timeout_floor, ensure_bundle_path, load_cfgsync_template_yaml, render_cfgsync_yaml_from_template, write_rendered_cfgsync, }; pub use repo::{ - BundleConfigSource, BundleConfigSourceError, CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, - CfgSyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, + BundleConfigSource, BundleConfigSourceError, CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, + CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, NodeConfigSource, NodeRegistration, RegisterNodeResponse, RegistrationPayload, StaticConfigSource, }; #[doc(hidden)] pub use repo::{ - CfgSyncFile, CfgSyncPayload, ConfigProvider, ConfigRepo, FileConfigProvider, - FileConfigProviderError, RegistrationResponse, RepoResponse, + CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigProvider, + ConfigRepo, FileConfigProvider, FileConfigProviderError, RegistrationResponse, RepoResponse, }; #[doc(hidden)] pub use server::CfgSyncState; -pub use server::{CfgsyncServerState, RunCfgsyncError, cfgsync_app, run_cfgsync}; +pub use server::{CfgsyncServerState, RunCfgsyncError, build_cfgsync_router, serve_cfgsync}; +#[doc(hidden)] +pub use server::{cfgsync_app, run_cfgsync}; diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs index b1581f7..6652367 100644 --- a/cfgsync/core/src/repo.rs +++ b/cfgsync/core/src/repo.rs @@ -159,7 +159,7 @@ impl NodeArtifactsPayload { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum CfgSyncErrorCode { +pub enum CfgsyncErrorCode { MissingConfig, NotReady, Internal, @@ -168,16 +168,16 @@ pub enum CfgSyncErrorCode { /// Structured error body returned by cfgsync server. #[derive(Debug, Clone, Serialize, Deserialize, Error)] #[error("{code:?}: {message}")] -pub struct CfgSyncErrorResponse { - pub code: CfgSyncErrorCode, +pub struct CfgsyncErrorResponse { + pub code: CfgsyncErrorCode, pub message: String, } -impl CfgSyncErrorResponse { +impl CfgsyncErrorResponse { #[must_use] pub fn missing_config(identifier: &str) -> Self { Self { - code: CfgSyncErrorCode::MissingConfig, + code: CfgsyncErrorCode::MissingConfig, message: format!("missing config for host {identifier}"), } } @@ -185,7 +185,7 @@ impl CfgSyncErrorResponse { #[must_use] pub fn not_ready(identifier: &str) -> Self { Self { - code: CfgSyncErrorCode::NotReady, + code: CfgsyncErrorCode::NotReady, message: format!("config for host {identifier} is not ready"), } } @@ -193,7 +193,7 @@ impl CfgSyncErrorResponse { #[must_use] pub fn internal(message: impl Into) -> Self { Self { - code: CfgSyncErrorCode::Internal, + code: CfgsyncErrorCode::Internal, message: message.into(), } } @@ -202,13 +202,13 @@ impl CfgSyncErrorResponse { /// Resolution outcome for a requested node identifier. pub enum ConfigResolveResponse { Config(NodeArtifactsPayload), - Error(CfgSyncErrorResponse), + Error(CfgsyncErrorResponse), } /// Outcome for a node registration request. pub enum RegisterNodeResponse { Registered, - Error(CfgSyncErrorResponse), + Error(CfgsyncErrorResponse), } /// Source of cfgsync node payloads. @@ -235,7 +235,7 @@ impl NodeConfigSource for StaticConfigSource { if self.configs.contains_key(®istration.identifier) { RegisterNodeResponse::Registered } else { - RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( + RegisterNodeResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )) } @@ -247,7 +247,7 @@ impl NodeConfigSource for StaticConfigSource { .cloned() .map_or_else( || { - ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )) }, @@ -372,7 +372,7 @@ mod tests { )) { ConfigResolveResponse::Config(_) => panic!("expected missing-config error"), ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); + assert!(matches!(error.code, CfgsyncErrorCode::MissingConfig)); assert!(error.message.contains("unknown-node")); } } @@ -515,3 +515,9 @@ pub type CfgSyncFile = NodeArtifactFile; #[doc(hidden)] pub type CfgSyncPayload = NodeArtifactsPayload; + +#[doc(hidden)] +pub type CfgSyncErrorCode = CfgsyncErrorCode; + +#[doc(hidden)] +pub type CfgSyncErrorResponse = CfgsyncErrorResponse; diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 2ab2da7..59970e1 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -4,7 +4,7 @@ use axum::{Json, Router, extract::State, http::StatusCode, response::IntoRespons use thiserror::Error; use crate::repo::{ - CfgSyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, + CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, RegisterNodeResponse, }; @@ -75,15 +75,15 @@ fn resolve_node_config_response( state.repo.resolve(registration) } -fn error_status(code: &CfgSyncErrorCode) -> StatusCode { +fn error_status(code: &CfgsyncErrorCode) -> StatusCode { match code { - CfgSyncErrorCode::MissingConfig => StatusCode::NOT_FOUND, - CfgSyncErrorCode::NotReady => StatusCode::TOO_EARLY, - CfgSyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + CfgsyncErrorCode::MissingConfig => StatusCode::NOT_FOUND, + CfgsyncErrorCode::NotReady => StatusCode::TOO_EARLY, + CfgsyncErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, } } -pub fn cfgsync_app(state: CfgsyncServerState) -> Router { +pub fn build_cfgsync_router(state: CfgsyncServerState) -> Router { Router::new() .route("/register", post(register_node)) .route("/node", post(node_config)) @@ -92,8 +92,8 @@ pub fn cfgsync_app(state: CfgsyncServerState) -> Router { } /// Runs cfgsync HTTP server on the provided port until shutdown/error. -pub async fn run_cfgsync(port: u16, state: CfgsyncServerState) -> Result<(), RunCfgsyncError> { - let app = cfgsync_app(state); +pub async fn serve_cfgsync(port: u16, state: CfgsyncServerState) -> Result<(), RunCfgsyncError> { + let app = build_cfgsync_router(state); println!("Server running on http://0.0.0.0:{port}"); let bind_addr = format!("0.0.0.0:{port}"); @@ -111,6 +111,11 @@ pub async fn run_cfgsync(port: u16, state: CfgsyncServerState) -> Result<(), Run #[doc(hidden)] pub type CfgSyncState = CfgsyncServerState; +#[doc(hidden)] +pub use build_cfgsync_router as cfgsync_app; +#[doc(hidden)] +pub use serve_cfgsync as run_cfgsync; + #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; @@ -119,7 +124,7 @@ mod tests { use super::{CfgsyncServerState, NodeRegistration, node_config, register_node}; use crate::repo::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncErrorCode, CfgSyncErrorResponse, ConfigResolveResponse, + CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, NodeConfigSource, RegisterNodeResponse, }; @@ -132,7 +137,7 @@ mod tests { if self.data.contains_key(®istration.identifier) { RegisterNodeResponse::Registered } else { - RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( + RegisterNodeResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )) } @@ -144,7 +149,7 @@ mod tests { .cloned() .map_or_else( || { - ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )) }, @@ -161,7 +166,7 @@ mod tests { impl NodeConfigSource for RegistrationAwareProvider { fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { if !self.data.contains_key(®istration.identifier) { - return RegisterNodeResponse::Error(CfgSyncErrorResponse::missing_config( + return RegisterNodeResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )); } @@ -182,7 +187,7 @@ mod tests { .expect("test registration store should not be poisoned"); if !registrations.contains_key(®istration.identifier) { - return ConfigResolveResponse::Error(CfgSyncErrorResponse::not_ready( + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )); } @@ -192,7 +197,7 @@ mod tests { .cloned() .map_or_else( || { - ConfigResolveResponse::Error(CfgSyncErrorResponse::missing_config( + ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )) }, @@ -248,9 +253,9 @@ mod tests { #[test] fn missing_config_error_uses_expected_code() { - let error = CfgSyncErrorResponse::missing_config("missing-node"); + let error = CfgsyncErrorResponse::missing_config("missing-node"); - assert!(matches!(error.code, CfgSyncErrorCode::MissingConfig)); + assert!(matches!(error.code, CfgsyncErrorCode::MissingConfig)); } #[tokio::test] diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index d0bea51..9951e3f 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{Context as _, Result, bail}; use cfgsync_core::{ - CFGSYNC_SCHEMA_VERSION, CfgSyncClient, NodeArtifactFile, NodeArtifactsPayload, + CFGSYNC_SCHEMA_VERSION, CfgsyncClient, NodeArtifactFile, NodeArtifactsPayload, NodeRegistration, RegistrationPayload, }; use thiserror::Error; @@ -26,7 +26,7 @@ async fn fetch_with_retry( payload: &NodeRegistration, server_addr: &str, ) -> Result { - let client = CfgSyncClient::new(server_addr); + let client = CfgsyncClient::new(server_addr); for attempt in 1..=FETCH_ATTEMPTS { match fetch_once(&client, payload).await { @@ -47,7 +47,7 @@ async fn fetch_with_retry( } async fn fetch_once( - client: &CfgSyncClient, + client: &CfgsyncClient, payload: &NodeRegistration, ) -> Result { let response = client.fetch_node_config(payload).await?; @@ -75,7 +75,7 @@ async fn pull_config_files(payload: NodeRegistration, server_addr: &str) -> Resu } async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result<()> { - let client = CfgSyncClient::new(server_addr); + let client = CfgsyncClient::new(server_addr); for attempt in 1..=FETCH_ATTEMPTS { match client.register_node(payload).await { @@ -188,7 +188,7 @@ mod tests { use cfgsync_core::{ CfgsyncServerState, NodeArtifactsBundle, NodeArtifactsBundleEntry, NodeArtifactsPayload, - StaticConfigSource, run_cfgsync, + StaticConfigSource, serve_cfgsync, }; use tempfile::tempdir; @@ -213,7 +213,9 @@ mod tests { let port = allocate_test_port(); let address = format!("http://127.0.0.1:{port}"); let server = tokio::spawn(async move { - run_cfgsync(port, state).await.expect("run cfgsync server"); + serve_cfgsync(port, state) + .await + .expect("run cfgsync server"); }); pull_config_files( diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 00f114c..c77b05d 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -3,7 +3,7 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; use cfgsync_adapter::{NodeArtifacts, NodeArtifactsCatalog, RegistrationConfigProvider}; use cfgsync_core::{ - BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, run_cfgsync, + BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, serve_cfgsync, }; use serde::Deserialize; use thiserror::Error; @@ -159,7 +159,7 @@ pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); let state = build_server_state(&config, &bundle_path)?; - run_cfgsync(config.port, state).await?; + serve_cfgsync(config.port, state).await?; Ok(()) } From 728b90b7702ce671466e0b49ca32285f8e2d46a6 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 12:30:53 +0100 Subject: [PATCH 14/38] Refactor cfgsync around external-facing modules --- cfgsync/adapter/src/artifacts.rs | 74 +++ cfgsync/adapter/src/deployment.rs | 118 +++++ cfgsync/adapter/src/lib.rs | 608 +--------------------- cfgsync/adapter/src/materializer.rs | 26 + cfgsync/adapter/src/registrations.rs | 36 ++ cfgsync/adapter/src/sources.rs | 365 +++++++++++++ cfgsync/core/src/bundle.rs | 6 - cfgsync/core/src/client.rs | 13 +- cfgsync/core/src/compat.rs | 20 + cfgsync/core/src/lib.rs | 33 +- cfgsync/core/src/protocol.rs | 258 +++++++++ cfgsync/core/src/repo.rs | 523 ------------------- cfgsync/core/src/server.rs | 20 +- cfgsync/core/src/source.rs | 264 ++++++++++ cfgsync/runtime/src/bin/cfgsync-server.rs | 6 +- cfgsync/runtime/src/client.rs | 21 +- cfgsync/runtime/src/lib.rs | 5 +- cfgsync/runtime/src/server.rs | 50 +- logos/runtime/ext/src/cfgsync/mod.rs | 12 +- testing-framework/core/src/cfgsync/mod.rs | 4 + 20 files changed, 1227 insertions(+), 1235 deletions(-) create mode 100644 cfgsync/adapter/src/artifacts.rs create mode 100644 cfgsync/adapter/src/deployment.rs create mode 100644 cfgsync/adapter/src/materializer.rs create mode 100644 cfgsync/adapter/src/registrations.rs create mode 100644 cfgsync/adapter/src/sources.rs create mode 100644 cfgsync/core/src/compat.rs create mode 100644 cfgsync/core/src/protocol.rs delete mode 100644 cfgsync/core/src/repo.rs create mode 100644 cfgsync/core/src/source.rs diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs new file mode 100644 index 0000000..4ad2693 --- /dev/null +++ b/cfgsync/adapter/src/artifacts.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; + +use cfgsync_artifacts::ArtifactFile; +use serde::{Deserialize, Serialize}; + +/// Per-node artifact payload served by cfgsync for one registered node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeArtifacts { + /// Stable node identifier resolved by the adapter. + pub identifier: String, + /// Files served to the node after cfgsync registration. + pub files: Vec, +} + +/// Materialized artifact files for a single registered node. +#[derive(Debug, Clone, Default)] +pub struct ArtifactSet { + files: Vec, +} + +impl ArtifactSet { + #[must_use] + pub fn new(files: Vec) -> Self { + Self { files } + } + + #[must_use] + pub fn files(&self) -> &[ArtifactFile] { + &self.files + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +/// Artifact payloads indexed by stable node identifier. +#[derive(Debug, Clone, Default)] +pub struct NodeArtifactsCatalog { + nodes: HashMap, +} + +impl NodeArtifactsCatalog { + #[must_use] + pub fn new(nodes: Vec) -> Self { + let nodes = nodes + .into_iter() + .map(|node| (node.identifier.clone(), node)) + .collect(); + + Self { nodes } + } + + #[must_use] + pub fn resolve(&self, identifier: &str) -> Option<&NodeArtifacts> { + self.nodes.get(identifier) + } + + #[must_use] + pub fn len(&self) -> usize { + self.nodes.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + #[must_use] + pub fn into_nodes(self) -> Vec { + self.nodes.into_values().collect() + } +} diff --git a/cfgsync/adapter/src/deployment.rs b/cfgsync/adapter/src/deployment.rs new file mode 100644 index 0000000..c2e37bc --- /dev/null +++ b/cfgsync/adapter/src/deployment.rs @@ -0,0 +1,118 @@ +use std::error::Error; + +use cfgsync_artifacts::ArtifactFile; +use thiserror::Error; + +use crate::{NodeArtifacts, NodeArtifactsCatalog}; + +/// Adapter contract for converting an application deployment model into +/// node-specific serialized config payloads. +pub trait DeploymentAdapter { + type Deployment; + type Node; + type NodeConfig; + type Error: Error + Send + Sync + 'static; + + fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; + + fn node_identifier(index: usize, node: &Self::Node) -> String; + + fn build_node_config( + deployment: &Self::Deployment, + node: &Self::Node, + ) -> Result; + + fn rewrite_for_hostnames( + deployment: &Self::Deployment, + node_index: usize, + hostnames: &[String], + config: &mut Self::NodeConfig, + ) -> Result<(), Self::Error>; + + fn serialize_node_config(config: &Self::NodeConfig) -> Result; +} + +/// High-level failures while building adapter output for cfgsync. +#[derive(Debug, Error)] +pub enum BuildCfgsyncNodesError { + #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] + HostnameCountMismatch { nodes: usize, hostnames: usize }, + #[error("cfgsync adapter failed: {source}")] + Adapter { + #[source] + source: super::DynCfgsyncError, + }, +} + +fn adapter_error(source: E) -> BuildCfgsyncNodesError +where + E: Error + Send + Sync + 'static, +{ + BuildCfgsyncNodesError::Adapter { + source: Box::new(source), + } +} + +/// Builds cfgsync node configs for a deployment by: +/// 1) validating hostname count, +/// 2) building each node config, +/// 3) rewriting host references, +/// 4) serializing each node payload. +pub fn build_cfgsync_node_configs( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result, BuildCfgsyncNodesError> { + Ok(build_node_artifact_catalog::(deployment, hostnames)?.into_nodes()) +} + +/// Builds cfgsync node configs and indexes them by stable identifier. +pub fn build_node_artifact_catalog( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result { + let nodes = E::nodes(deployment); + ensure_hostname_count(nodes.len(), hostnames.len())?; + + let mut output = Vec::with_capacity(nodes.len()); + for (index, node) in nodes.iter().enumerate() { + output.push(build_node_entry::(deployment, node, index, hostnames)?); + } + + Ok(NodeArtifactsCatalog::new(output)) +} + +fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { + if nodes != hostnames { + return Err(BuildCfgsyncNodesError::HostnameCountMismatch { nodes, hostnames }); + } + + Ok(()) +} + +fn build_node_entry( + deployment: &E::Deployment, + node: &E::Node, + index: usize, + hostnames: &[String], +) -> Result { + let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; + let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; + + Ok(NodeArtifacts { + identifier: E::node_identifier(index, node), + files: vec![ArtifactFile::new("/config.yaml", &config_yaml)], + }) +} + +fn build_rewritten_node_config( + deployment: &E::Deployment, + node: &E::Node, + index: usize, + hostnames: &[String], +) -> Result { + let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; + E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) + .map_err(adapter_error)?; + + Ok(node_config) +} diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index aee1864..c530f62 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -1,596 +1,16 @@ -use std::{collections::HashMap, error::Error, sync::Mutex}; +mod artifacts; +mod deployment; +mod materializer; +mod registrations; +mod sources; -use cfgsync_artifacts::ArtifactFile; -use cfgsync_core::{ - CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactsPayload, NodeConfigSource, - NodeRegistration, RegisterNodeResponse, +pub use artifacts::{ArtifactSet, NodeArtifacts, NodeArtifactsCatalog}; +pub use deployment::{ + BuildCfgsyncNodesError, DeploymentAdapter, build_cfgsync_node_configs, + build_node_artifact_catalog, }; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -/// Type-erased cfgsync adapter error used to preserve source context. -pub type DynCfgsyncError = Box; - -/// Per-node artifact payload served by cfgsync for one registered node. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeArtifacts { - /// Stable node identifier resolved by the adapter. - pub identifier: String, - /// Files served to the node after cfgsync registration. - pub files: Vec, -} - -/// Materialized artifact files for a single registered node. -#[derive(Debug, Clone, Default)] -pub struct ArtifactSet { - files: Vec, -} - -/// Immutable view of registrations currently known to cfgsync. -#[derive(Debug, Clone, Default)] -pub struct RegistrationSet { - registrations: Vec, -} - -impl RegistrationSet { - #[must_use] - pub fn new(registrations: Vec) -> Self { - Self { registrations } - } - - #[must_use] - pub fn len(&self) -> usize { - self.registrations.len() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.registrations.is_empty() - } - - #[must_use] - pub fn iter(&self) -> impl Iterator { - self.registrations.iter() - } - - #[must_use] - pub fn get(&self, identifier: &str) -> Option<&NodeRegistration> { - self.registrations - .iter() - .find(|registration| registration.identifier == identifier) - } -} - -impl ArtifactSet { - #[must_use] - pub fn new(files: Vec) -> Self { - Self { files } - } - - #[must_use] - pub fn files(&self) -> &[ArtifactFile] { - &self.files - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.files.is_empty() - } -} - -/// Artifact payloads indexed by stable node identifier. -#[derive(Debug, Clone, Default)] -pub struct NodeArtifactsCatalog { - nodes: HashMap, -} - -impl NodeArtifactsCatalog { - #[must_use] - pub fn new(nodes: Vec) -> Self { - let nodes = nodes - .into_iter() - .map(|node| (node.identifier.clone(), node)) - .collect(); - - Self { nodes } - } - - #[must_use] - pub fn resolve(&self, identifier: &str) -> Option<&NodeArtifacts> { - self.nodes.get(identifier) - } - - #[must_use] - pub fn len(&self) -> usize { - self.nodes.len() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - #[must_use] - pub fn into_nodes(self) -> Vec { - self.nodes.into_values().collect() - } - - #[doc(hidden)] - #[must_use] - pub fn into_configs(self) -> Vec { - self.into_nodes() - } -} - -/// Adapter-side materialization contract for a single registered node. -pub trait NodeArtifactsMaterializer: Send + Sync { - fn materialize( - &self, - registration: &NodeRegistration, - registrations: &RegistrationSet, - ) -> Result, DynCfgsyncError>; -} - -/// Backward-compatible alias for the previous materializer trait name. -pub trait CfgsyncMaterializer: NodeArtifactsMaterializer {} - -impl CfgsyncMaterializer for T where T: NodeArtifactsMaterializer + ?Sized {} - -/// Adapter contract for materializing a whole registration set into -/// per-node cfgsync artifacts. -pub trait RegistrationSetMaterializer: Send + Sync { - fn materialize_snapshot( - &self, - registrations: &RegistrationSet, - ) -> Result, DynCfgsyncError>; -} - -/// Backward-compatible alias for the previous snapshot materializer trait name. -pub trait CfgsyncSnapshotMaterializer: RegistrationSetMaterializer {} - -impl CfgsyncSnapshotMaterializer for T where T: RegistrationSetMaterializer + ?Sized {} - -impl NodeArtifactsMaterializer for NodeArtifactsCatalog { - fn materialize( - &self, - registration: &NodeRegistration, - _registrations: &RegistrationSet, - ) -> Result, DynCfgsyncError> { - let artifacts = self - .resolve(®istration.identifier) - .map(build_node_artifacts_from_config); - - Ok(artifacts) - } -} - -impl RegistrationSetMaterializer for NodeArtifactsCatalog { - fn materialize_snapshot( - &self, - _registrations: &RegistrationSet, - ) -> Result, DynCfgsyncError> { - Ok(Some(self.clone())) - } -} - -/// Registration-aware provider backed by an adapter materializer. -pub struct RegistrationConfigProvider { - materializer: M, - registrations: Mutex>, -} - -impl RegistrationConfigProvider { - #[must_use] - pub fn new(materializer: M) -> Self { - Self { - materializer, - registrations: Mutex::new(HashMap::new()), - } - } - - fn registration_for(&self, identifier: &str) -> Option { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - registrations.get(identifier).cloned() - } - - fn registration_set(&self) -> RegistrationSet { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - RegistrationSet::new(registrations.values().cloned().collect()) - } -} - -/// Registration-aware provider backed by a snapshot materializer. -pub struct SnapshotConfigProvider { - materializer: M, - registrations: Mutex>, -} - -impl SnapshotConfigProvider { - #[must_use] - pub fn new(materializer: M) -> Self { - Self { - materializer, - registrations: Mutex::new(HashMap::new()), - } - } - - fn registration_for(&self, identifier: &str) -> Option { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - registrations.get(identifier).cloned() - } - - fn registration_set(&self) -> RegistrationSet { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - RegistrationSet::new(registrations.values().cloned().collect()) - } -} - -impl NodeConfigSource for SnapshotConfigProvider -where - M: RegistrationSetMaterializer, -{ - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { - let mut registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - registrations.insert(registration.identifier.clone(), registration); - - RegisterNodeResponse::Registered - } - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { - let registration = match self.registration_for(®istration.identifier) { - Some(registration) => registration, - None => { - return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )); - } - }; - - let registrations = self.registration_set(); - let catalog = match self.materializer.materialize_snapshot(®istrations) { - Ok(Some(catalog)) => catalog, - Ok(None) => { - return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )); - } - Err(error) => { - return ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( - "failed to materialize config snapshot: {error}" - ))); - } - }; - - match catalog.resolve(®istration.identifier) { - Some(config) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( - config.files.clone(), - )), - None => ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( - ®istration.identifier, - )), - } - } -} - -impl NodeConfigSource for RegistrationConfigProvider -where - M: NodeArtifactsMaterializer, -{ - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { - let mut registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - registrations.insert(registration.identifier.clone(), registration); - - RegisterNodeResponse::Registered - } - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { - let registration = match self.registration_for(®istration.identifier) { - Some(registration) => registration, - None => { - return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )); - } - }; - let registrations = self.registration_set(); - - match self.materializer.materialize(®istration, ®istrations) { - Ok(Some(artifacts)) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( - artifacts.files().to_vec(), - )), - Ok(None) => ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )), - Err(error) => ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( - "failed to materialize config for host {}: {error}", - registration.identifier - ))), - } - } -} - -/// Adapter contract for converting an application deployment model into -/// node-specific serialized config payloads. -pub trait CfgsyncEnv { - type Deployment; - type Node; - type NodeConfig; - type Error: Error + Send + Sync + 'static; - - fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; - - fn node_identifier(index: usize, node: &Self::Node) -> String; - - fn build_node_config( - deployment: &Self::Deployment, - node: &Self::Node, - ) -> Result; - - fn rewrite_for_hostnames( - deployment: &Self::Deployment, - node_index: usize, - hostnames: &[String], - config: &mut Self::NodeConfig, - ) -> Result<(), Self::Error>; - - fn serialize_node_config(config: &Self::NodeConfig) -> Result; -} - -/// Preferred public name for application-side cfgsync integration. -pub trait DeploymentAdapter: CfgsyncEnv {} - -impl DeploymentAdapter for T where T: CfgsyncEnv + ?Sized {} - -/// High-level failures while building adapter output for cfgsync. -#[derive(Debug, Error)] -pub enum BuildCfgsyncNodesError { - #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] - HostnameCountMismatch { nodes: usize, hostnames: usize }, - #[error("cfgsync adapter failed: {source}")] - Adapter { - #[source] - source: DynCfgsyncError, - }, -} - -fn adapter_error(source: E) -> BuildCfgsyncNodesError -where - E: Error + Send + Sync + 'static, -{ - BuildCfgsyncNodesError::Adapter { - source: Box::new(source), - } -} - -/// Builds cfgsync node configs for a deployment by: -/// 1) validating hostname count, -/// 2) building each node config, -/// 3) rewriting host references, -/// 4) serializing each node payload. -pub fn build_cfgsync_node_configs( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result, BuildCfgsyncNodesError> { - Ok(build_node_artifact_catalog::(deployment, hostnames)?.into_nodes()) -} - -/// Builds cfgsync node configs and indexes them by stable identifier. -pub fn build_node_artifact_catalog( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result { - let nodes = E::nodes(deployment); - ensure_hostname_count(nodes.len(), hostnames.len())?; - - let mut output = Vec::with_capacity(nodes.len()); - for (index, node) in nodes.iter().enumerate() { - output.push(build_node_entry::(deployment, node, index, hostnames)?); - } - - Ok(NodeArtifactsCatalog::new(output)) -} - -#[doc(hidden)] -pub fn build_cfgsync_node_catalog( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result { - build_node_artifact_catalog::(deployment, hostnames) -} - -fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { - if nodes != hostnames { - return Err(BuildCfgsyncNodesError::HostnameCountMismatch { nodes, hostnames }); - } - - Ok(()) -} - -fn build_node_entry( - deployment: &E::Deployment, - node: &E::Node, - index: usize, - hostnames: &[String], -) -> Result { - let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; - let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; - - Ok(NodeArtifacts { - identifier: E::node_identifier(index, node), - files: vec![ArtifactFile::new("/config.yaml", &config_yaml)], - }) -} - -fn build_rewritten_node_config( - deployment: &E::Deployment, - node: &E::Node, - index: usize, - hostnames: &[String], -) -> Result { - let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; - E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) - .map_err(adapter_error)?; - - Ok(node_config) -} - -fn build_node_artifacts_from_config(config: &NodeArtifacts) -> ArtifactSet { - ArtifactSet::new(config.files.clone()) -} - -#[doc(hidden)] -pub type CfgsyncNodeConfig = NodeArtifacts; - -#[doc(hidden)] -pub type CfgsyncNodeArtifacts = ArtifactSet; - -#[doc(hidden)] -pub type RegistrationSnapshot = RegistrationSet; - -#[doc(hidden)] -pub type CfgsyncNodeCatalog = NodeArtifactsCatalog; - -#[doc(hidden)] -pub type MaterializingConfigProvider = RegistrationConfigProvider; - -#[doc(hidden)] -pub type SnapshotMaterializingConfigProvider = SnapshotConfigProvider; - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use cfgsync_artifacts::ArtifactFile; - use cfgsync_core::{ - CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, - }; - - use super::{ - ArtifactSet, DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, - NodeArtifactsMaterializer, RegistrationConfigProvider, RegistrationSet, - }; - - #[test] - fn catalog_resolves_identifier() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); - - let node = catalog.resolve("node-1").expect("resolve node config"); - - assert_eq!(node.files[0].content, "key: value"); - } - - #[test] - fn materializing_provider_resolves_registered_node() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); - let provider = RegistrationConfigProvider::new(catalog); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); - - let _ = provider.register(registration.clone()); - - match provider.resolve(®istration) { - ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.files()[0].path, "/config.yaml") - } - ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), - } - } - - #[test] - fn materializing_provider_reports_not_ready_before_registration() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); - let provider = RegistrationConfigProvider::new(catalog); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); - - match provider.resolve(®istration) { - ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), - ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) - } - } - } - - struct ThresholdMaterializer { - calls: AtomicUsize, - } - - impl NodeArtifactsMaterializer for ThresholdMaterializer { - fn materialize( - &self, - registration: &NodeRegistration, - registrations: &RegistrationSet, - ) -> Result, DynCfgsyncError> { - self.calls.fetch_add(1, Ordering::SeqCst); - - if registrations.len() < 2 { - return Ok(None); - } - - let peer_count = registrations.iter().count(); - let files = vec![ - ArtifactFile::new("/config.yaml", format!("id: {}", registration.identifier)), - ArtifactFile::new("/shared.yaml", format!("peers: {peer_count}")), - ]; - - Ok(Some(ArtifactSet::new(files))) - } - } - - #[test] - fn materializing_provider_uses_registration_snapshot_for_readiness() { - let provider = RegistrationConfigProvider::new(ThresholdMaterializer { - calls: AtomicUsize::new(0), - }); - let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); - let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); - - let _ = provider.register(node_a.clone()); - - match provider.resolve(&node_a) { - ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), - ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) - } - } - - let _ = provider.register(node_b); - - match provider.resolve(&node_a) { - ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.files()[0].content, "id: node-a"); - assert_eq!(payload.files()[1].content, "peers: 2"); - } - ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), - } - } -} +pub use materializer::{ + DynCfgsyncError, NodeArtifactsMaterializer, RegistrationSnapshotMaterializer, +}; +pub use registrations::RegistrationSnapshot; +pub use sources::{MaterializingConfigSource, SnapshotConfigSource}; diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs new file mode 100644 index 0000000..5680960 --- /dev/null +++ b/cfgsync/adapter/src/materializer.rs @@ -0,0 +1,26 @@ +use std::error::Error; + +use cfgsync_core::NodeRegistration; + +use crate::{ArtifactSet, NodeArtifactsCatalog, RegistrationSnapshot}; + +/// Type-erased cfgsync adapter error used to preserve source context. +pub type DynCfgsyncError = Box; + +/// Adapter-side materialization contract for a single registered node. +pub trait NodeArtifactsMaterializer: Send + Sync { + fn materialize( + &self, + registration: &NodeRegistration, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError>; +} + +/// Adapter contract for materializing a whole registration snapshot into +/// per-node cfgsync artifacts. +pub trait RegistrationSnapshotMaterializer: Send + Sync { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError>; +} diff --git a/cfgsync/adapter/src/registrations.rs b/cfgsync/adapter/src/registrations.rs new file mode 100644 index 0000000..3926b57 --- /dev/null +++ b/cfgsync/adapter/src/registrations.rs @@ -0,0 +1,36 @@ +use cfgsync_core::NodeRegistration; + +/// Immutable view of registrations currently known to cfgsync. +#[derive(Debug, Clone, Default)] +pub struct RegistrationSnapshot { + registrations: Vec, +} + +impl RegistrationSnapshot { + #[must_use] + pub fn new(registrations: Vec) -> Self { + Self { registrations } + } + + #[must_use] + pub fn len(&self) -> usize { + self.registrations.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.registrations.is_empty() + } + + #[must_use] + pub fn iter(&self) -> impl Iterator { + self.registrations.iter() + } + + #[must_use] + pub fn get(&self, identifier: &str) -> Option<&NodeRegistration> { + self.registrations + .iter() + .find(|registration| registration.identifier == identifier) + } +} diff --git a/cfgsync/adapter/src/sources.rs b/cfgsync/adapter/src/sources.rs new file mode 100644 index 0000000..5008515 --- /dev/null +++ b/cfgsync/adapter/src/sources.rs @@ -0,0 +1,365 @@ +use std::{collections::HashMap, sync::Mutex}; + +use cfgsync_core::{ + CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactsPayload, NodeConfigSource, + NodeRegistration, RegisterNodeResponse, +}; + +use crate::{ + ArtifactSet, DynCfgsyncError, NodeArtifactsCatalog, NodeArtifactsMaterializer, + RegistrationSnapshot, RegistrationSnapshotMaterializer, +}; + +impl NodeArtifactsMaterializer for NodeArtifactsCatalog { + fn materialize( + &self, + registration: &NodeRegistration, + _registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + Ok(self + .resolve(®istration.identifier) + .map(build_artifact_set_from_catalog_entry)) + } +} + +impl RegistrationSnapshotMaterializer for NodeArtifactsCatalog { + fn materialize_snapshot( + &self, + _registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + Ok(Some(self.clone())) + } +} + +/// Registration-aware source backed by an adapter materializer. +pub struct MaterializingConfigSource { + materializer: M, + registrations: Mutex>, +} + +impl MaterializingConfigSource { + #[must_use] + pub fn new(materializer: M) -> Self { + Self { + materializer, + registrations: Mutex::new(HashMap::new()), + } + } + + fn registration_for(&self, identifier: &str) -> Option { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + registrations.get(identifier).cloned() + } + + fn registration_snapshot(&self) -> RegistrationSnapshot { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + RegistrationSnapshot::new(registrations.values().cloned().collect()) + } +} + +impl NodeConfigSource for MaterializingConfigSource +where + M: NodeArtifactsMaterializer, +{ + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { + let mut registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + registrations.insert(registration.identifier.clone(), registration); + + RegisterNodeResponse::Registered + } + + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { + let registration = match self.registration_for(®istration.identifier) { + Some(registration) => registration, + None => { + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + }; + let registrations = self.registration_snapshot(); + + match self.materializer.materialize(®istration, ®istrations) { + Ok(Some(artifacts)) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( + artifacts.files().to_vec(), + )), + Ok(None) => ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( + ®istration.identifier, + )), + Err(error) => ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( + "failed to materialize config for host {}: {error}", + registration.identifier + ))), + } + } +} + +/// Registration-aware source backed by a snapshot materializer. +pub struct SnapshotConfigSource { + materializer: M, + registrations: Mutex>, +} + +impl SnapshotConfigSource { + #[must_use] + pub fn new(materializer: M) -> Self { + Self { + materializer, + registrations: Mutex::new(HashMap::new()), + } + } + + fn registration_for(&self, identifier: &str) -> Option { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + registrations.get(identifier).cloned() + } + + fn registration_snapshot(&self) -> RegistrationSnapshot { + let registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + + RegistrationSnapshot::new(registrations.values().cloned().collect()) + } +} + +impl NodeConfigSource for SnapshotConfigSource +where + M: RegistrationSnapshotMaterializer, +{ + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { + let mut registrations = self + .registrations + .lock() + .expect("cfgsync registration store should not be poisoned"); + registrations.insert(registration.identifier.clone(), registration); + + RegisterNodeResponse::Registered + } + + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { + let registration = match self.registration_for(®istration.identifier) { + Some(registration) => registration, + None => { + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + }; + + let registrations = self.registration_snapshot(); + let catalog = match self.materializer.materialize_snapshot(®istrations) { + Ok(Some(catalog)) => catalog, + Ok(None) => { + return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( + ®istration.identifier, + )); + } + Err(error) => { + return ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( + "failed to materialize config snapshot: {error}" + ))); + } + }; + + match catalog.resolve(®istration.identifier) { + Some(config) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( + config.files.clone(), + )), + None => ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( + ®istration.identifier, + )), + } + } +} + +fn build_artifact_set_from_catalog_entry(config: &crate::NodeArtifacts) -> ArtifactSet { + ArtifactSet::new(config.files.clone()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use cfgsync_artifacts::ArtifactFile; + use cfgsync_core::{ + CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, + }; + + use super::{MaterializingConfigSource, SnapshotConfigSource}; + use crate::{ + DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, NodeArtifactsMaterializer, + RegistrationSnapshot, RegistrationSnapshotMaterializer, + }; + + #[test] + fn catalog_resolves_identifier() { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { + identifier: "node-1".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], + }]); + + let node = catalog.resolve("node-1").expect("resolve node config"); + + assert_eq!(node.files[0].content, "key: value"); + } + + #[test] + fn materializing_source_resolves_registered_node() { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { + identifier: "node-1".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], + }]); + let source = MaterializingConfigSource::new(catalog); + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + + let _ = source.register(registration.clone()); + + match source.resolve(®istration) { + ConfigResolveResponse::Config(payload) => { + assert_eq!(payload.files()[0].path, "/config.yaml") + } + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), + } + } + + #[test] + fn materializing_source_reports_not_ready_before_registration() { + let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { + identifier: "node-1".to_owned(), + files: vec![ArtifactFile::new("/config.yaml", "key: value")], + }]); + let source = MaterializingConfigSource::new(catalog); + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + + match source.resolve(®istration) { + ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) + } + } + } + + struct ThresholdMaterializer { + calls: AtomicUsize, + } + + impl NodeArtifactsMaterializer for ThresholdMaterializer { + fn materialize( + &self, + registration: &NodeRegistration, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + self.calls.fetch_add(1, Ordering::SeqCst); + + if registrations.len() < 2 { + return Ok(None); + } + + let peer_count = registrations.iter().count(); + let files = vec![ + ArtifactFile::new("/config.yaml", format!("id: {}", registration.identifier)), + ArtifactFile::new("/peers.txt", peer_count.to_string()), + ]; + + Ok(Some(crate::ArtifactSet::new(files))) + } + } + + #[test] + fn materializing_source_passes_registration_snapshot() { + let source = MaterializingConfigSource::new(ThresholdMaterializer { + calls: AtomicUsize::new(0), + }); + let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); + let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + + let _ = source.register(node_a.clone()); + + match source.resolve(&node_a) { + ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) + } + } + + let _ = source.register(node_b); + + match source.resolve(&node_a) { + ConfigResolveResponse::Config(payload) => { + assert_eq!(payload.files()[0].content, "id: node-a"); + assert_eq!(payload.files()[1].content, "2"); + } + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), + } + + assert_eq!(source.materializer.calls.load(Ordering::SeqCst), 2); + } + + struct ThresholdSnapshotMaterializer; + + impl RegistrationSnapshotMaterializer for ThresholdSnapshotMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + if registrations.len() < 2 { + return Ok(None); + } + + Ok(Some(NodeArtifactsCatalog::new( + registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new( + "/config.yaml", + format!("peer_count: {}", registrations.len()), + )], + }) + .collect(), + ))) + } + } + + #[test] + fn snapshot_source_materializes_from_registration_snapshot() { + let source = SnapshotConfigSource::new(ThresholdSnapshotMaterializer); + let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); + let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + + let _ = source.register(node_a.clone()); + + match source.resolve(&node_a) { + ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) + } + } + + let _ = source.register(node_b); + + match source.resolve(&node_a) { + ConfigResolveResponse::Config(payload) => { + assert_eq!(payload.files()[0].content, "peer_count: 2"); + } + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), + } + } +} diff --git a/cfgsync/core/src/bundle.rs b/cfgsync/core/src/bundle.rs index d8f6cab..5281b13 100644 --- a/cfgsync/core/src/bundle.rs +++ b/cfgsync/core/src/bundle.rs @@ -24,9 +24,3 @@ pub struct NodeArtifactsBundleEntry { #[serde(default)] pub files: Vec, } - -#[doc(hidden)] -pub type CfgSyncBundle = NodeArtifactsBundle; - -#[doc(hidden)] -pub type CfgSyncBundleNode = NodeArtifactsBundleEntry; diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index e599c35..4032c35 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -1,7 +1,7 @@ use serde::Serialize; use thiserror::Error; -use crate::repo::{CfgsyncErrorCode, CfgsyncErrorResponse, NodeArtifactsPayload, NodeRegistration}; +use crate::{CfgsyncErrorCode, CfgsyncErrorResponse, NodeArtifactsPayload, NodeRegistration}; /// cfgsync client-side request/response failures. #[derive(Debug, Error)] @@ -63,14 +63,6 @@ impl CfgsyncClient { self.post_json("/node", payload).await } - /// Fetches `/init-with-node` payload for a node identifier. - pub async fn fetch_init_with_node_config( - &self, - payload: &NodeRegistration, - ) -> Result { - self.post_json("/init-with-node", payload).await - } - pub async fn fetch_node_config_status( &self, payload: &NodeRegistration, @@ -155,6 +147,3 @@ impl CfgsyncClient { } } } - -#[doc(hidden)] -pub type CfgSyncClient = CfgsyncClient; diff --git a/cfgsync/core/src/compat.rs b/cfgsync/core/src/compat.rs new file mode 100644 index 0000000..ea1cb17 --- /dev/null +++ b/cfgsync/core/src/compat.rs @@ -0,0 +1,20 @@ +#![doc(hidden)] + +pub use crate::{ + bundle::{NodeArtifactsBundle as CfgSyncBundle, NodeArtifactsBundleEntry as CfgSyncBundleNode}, + client::CfgsyncClient as CfgSyncClient, + protocol::{ + CfgsyncErrorCode as CfgSyncErrorCode, CfgsyncErrorResponse as CfgSyncErrorResponse, + ConfigResolveResponse as RepoResponse, NodeArtifactFile as CfgSyncFile, + NodeArtifactsPayload as CfgSyncPayload, RegisterNodeResponse as RegistrationResponse, + }, + server::{ + CfgsyncServerState as CfgSyncState, build_legacy_cfgsync_router as cfgsync_app, + serve_cfgsync as run_cfgsync, + }, + source::{ + BundleConfigSource as FileConfigProvider, + BundleConfigSourceError as FileConfigProviderError, NodeConfigSource as ConfigProvider, + StaticConfigSource as ConfigRepo, + }, +}; diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index aaac4c7..b7664bb 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -1,33 +1,26 @@ pub mod bundle; pub mod client; +#[doc(hidden)] +pub mod compat; +pub mod protocol; pub mod render; -pub mod repo; pub mod server; +pub mod source; -#[doc(hidden)] -pub use bundle::{CfgSyncBundle, CfgSyncBundleNode}; pub use bundle::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; -#[doc(hidden)] -pub use client::CfgSyncClient; pub use client::{CfgsyncClient, ClientError, ConfigFetchStatus}; +pub use protocol::{ + CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, CfgsyncErrorResponse, ConfigResolveResponse, + NodeArtifactFile, NodeArtifactsPayload, NodeRegistration, RegisterNodeResponse, + RegistrationPayload, +}; pub use render::{ CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, apply_timeout_floor, ensure_bundle_path, load_cfgsync_template_yaml, render_cfgsync_yaml_from_template, write_rendered_cfgsync, }; -pub use repo::{ - BundleConfigSource, BundleConfigSourceError, CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, - CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, - NodeConfigSource, NodeRegistration, RegisterNodeResponse, RegistrationPayload, - StaticConfigSource, -}; -#[doc(hidden)] -pub use repo::{ - CfgSyncErrorCode, CfgSyncErrorResponse, CfgSyncFile, CfgSyncPayload, ConfigProvider, - ConfigRepo, FileConfigProvider, FileConfigProviderError, RegistrationResponse, RepoResponse, -}; -#[doc(hidden)] -pub use server::CfgSyncState; pub use server::{CfgsyncServerState, RunCfgsyncError, build_cfgsync_router, serve_cfgsync}; -#[doc(hidden)] -pub use server::{cfgsync_app, run_cfgsync}; +pub use source::{ + BundleConfigSource, BundleConfigSourceError, BundleLoadError, NodeConfigSource, + StaticConfigSource, bundle_to_payload_map, load_bundle, +}; diff --git a/cfgsync/core/src/protocol.rs b/cfgsync/core/src/protocol.rs new file mode 100644 index 0000000..ca8f205 --- /dev/null +++ b/cfgsync/core/src/protocol.rs @@ -0,0 +1,258 @@ +use std::net::Ipv4Addr; + +use cfgsync_artifacts::ArtifactFile; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; +use serde_json::Value; +use thiserror::Error; + +/// Schema version served by cfgsync payload responses. +pub const CFGSYNC_SCHEMA_VERSION: u16 = 1; + +/// Canonical cfgsync file type used in payloads and bundles. +pub type NodeArtifactFile = ArtifactFile; + +/// Payload returned by cfgsync server for one node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeArtifactsPayload { + /// Payload schema version for compatibility checks. + pub schema_version: u16, + /// Files that must be written on the target node. + #[serde(default)] + pub files: Vec, +} + +/// Adapter-owned registration payload stored alongside a generic node identity. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RegistrationPayload { + raw_json: Option, +} + +impl RegistrationPayload { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.raw_json.is_none() + } + + pub fn from_serializable(value: &T) -> Result + where + T: Serialize, + { + Ok(Self { + raw_json: Some(serde_json::to_string(value)?), + }) + } + + pub fn from_json_str(raw_json: &str) -> Result { + let value: Value = serde_json::from_str(raw_json)?; + + Ok(Self { + raw_json: Some(serde_json::to_string(&value)?), + }) + } + + pub fn deserialize(&self) -> Result, serde_json::Error> + where + T: DeserializeOwned, + { + self.raw_json + .as_ref() + .map(|raw_json| serde_json::from_str(raw_json)) + .transpose() + } + + #[must_use] + pub fn raw_json(&self) -> Option<&str> { + self.raw_json.as_deref() + } +} + +impl Serialize for RegistrationPayload { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.raw_json.as_deref() { + Some(raw_json) => { + let value: Value = + serde_json::from_str(raw_json).map_err(serde::ser::Error::custom)?; + value.serialize(serializer) + } + None => serializer.serialize_none(), + } + } +} + +impl<'de> Deserialize<'de> for RegistrationPayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + let raw_json = value + .map(|value| serde_json::to_string(&value).map_err(serde::de::Error::custom)) + .transpose()?; + + Ok(Self { raw_json }) + } +} + +/// Node metadata recorded before config materialization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeRegistration { + pub identifier: String, + pub ip: Ipv4Addr, + #[serde(default, skip_serializing_if = "RegistrationPayload::is_empty")] + pub metadata: RegistrationPayload, +} + +impl NodeRegistration { + #[must_use] + pub fn new(identifier: impl Into, ip: Ipv4Addr) -> Self { + Self { + identifier: identifier.into(), + ip, + metadata: RegistrationPayload::default(), + } + } + + pub fn with_metadata(mut self, metadata: &T) -> Result + where + T: Serialize, + { + self.metadata = RegistrationPayload::from_serializable(metadata)?; + Ok(self) + } + + #[must_use] + pub fn with_payload(mut self, payload: RegistrationPayload) -> Self { + self.metadata = payload; + self + } +} + +impl NodeArtifactsPayload { + #[must_use] + pub fn from_files(files: Vec) -> Self { + Self { + schema_version: CFGSYNC_SCHEMA_VERSION, + files, + } + } + + #[must_use] + pub fn files(&self) -> &[NodeArtifactFile] { + &self.files + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CfgsyncErrorCode { + MissingConfig, + NotReady, + Internal, +} + +/// Structured error body returned by cfgsync server. +#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[error("{code:?}: {message}")] +pub struct CfgsyncErrorResponse { + pub code: CfgsyncErrorCode, + pub message: String, +} + +impl CfgsyncErrorResponse { + #[must_use] + pub fn missing_config(identifier: &str) -> Self { + Self { + code: CfgsyncErrorCode::MissingConfig, + message: format!("missing config for host {identifier}"), + } + } + + #[must_use] + pub fn not_ready(identifier: &str) -> Self { + Self { + code: CfgsyncErrorCode::NotReady, + message: format!("config for host {identifier} is not ready"), + } + } + + #[must_use] + pub fn internal(message: impl Into) -> Self { + Self { + code: CfgsyncErrorCode::Internal, + message: message.into(), + } + } +} + +/// Resolution outcome for a requested node identifier. +pub enum ConfigResolveResponse { + Config(NodeArtifactsPayload), + Error(CfgsyncErrorResponse), +} + +/// Outcome for a node registration request. +pub enum RegisterNodeResponse { + Registered, + Error(CfgsyncErrorResponse), +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + use serde_json::Value; + + use super::{NodeRegistration, RegistrationPayload}; + + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct ExampleRegistration { + network_port: u16, + service: String, + } + + #[test] + fn registration_payload_round_trips_typed_value() { + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")) + .with_metadata(&ExampleRegistration { + network_port: 3000, + service: "blend".to_owned(), + }) + .expect("serialize registration metadata"); + + let encoded = serde_json::to_value(®istration).expect("serialize registration"); + let metadata = encoded.get("metadata").expect("registration metadata"); + assert_eq!(metadata.get("network_port"), Some(&Value::from(3000u16))); + assert_eq!(metadata.get("service"), Some(&Value::from("blend"))); + + let decoded: NodeRegistration = + serde_json::from_value(encoded).expect("deserialize registration"); + let typed: ExampleRegistration = decoded + .metadata + .deserialize() + .expect("deserialize metadata") + .expect("registration metadata value"); + + assert_eq!(typed.network_port, 3000); + assert_eq!(typed.service, "blend"); + } + + #[test] + fn registration_payload_accepts_raw_json() { + let payload = + RegistrationPayload::from_json_str(r#"{"network_port":3000}"#).expect("parse raw json"); + + assert_eq!(payload.raw_json(), Some(r#"{"network_port":3000}"#)); + } +} diff --git a/cfgsync/core/src/repo.rs b/cfgsync/core/src/repo.rs deleted file mode 100644 index 6652367..0000000 --- a/cfgsync/core/src/repo.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::{collections::HashMap, fs, net::Ipv4Addr, path::Path, sync::Arc}; - -use cfgsync_artifacts::ArtifactFile; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; -use serde_json::Value; -use thiserror::Error; - -use crate::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; - -/// Schema version served by cfgsync payload responses. -pub const CFGSYNC_SCHEMA_VERSION: u16 = 1; - -/// Canonical cfgsync file type used in payloads and bundles. -pub type NodeArtifactFile = ArtifactFile; - -/// Payload returned by cfgsync server for one node. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeArtifactsPayload { - /// Payload schema version for compatibility checks. - pub schema_version: u16, - /// Files that must be written on the target node. - #[serde(default)] - pub files: Vec, -} - -/// Adapter-owned registration payload stored alongside a generic node identity. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RegistrationPayload { - raw_json: Option, -} - -impl RegistrationPayload { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.raw_json.is_none() - } - - pub fn from_serializable(value: &T) -> Result - where - T: Serialize, - { - Ok(Self { - raw_json: Some(serde_json::to_string(value)?), - }) - } - - pub fn from_json_str(raw_json: &str) -> Result { - let value: Value = serde_json::from_str(raw_json)?; - - Ok(Self { - raw_json: Some(serde_json::to_string(&value)?), - }) - } - - pub fn deserialize(&self) -> Result, serde_json::Error> - where - T: DeserializeOwned, - { - self.raw_json - .as_ref() - .map(|raw_json| serde_json::from_str(raw_json)) - .transpose() - } - - #[must_use] - pub fn raw_json(&self) -> Option<&str> { - self.raw_json.as_deref() - } -} - -impl Serialize for RegistrationPayload { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self.raw_json.as_deref() { - Some(raw_json) => { - let value: Value = - serde_json::from_str(raw_json).map_err(serde::ser::Error::custom)?; - value.serialize(serializer) - } - None => serializer.serialize_none(), - } - } -} - -impl<'de> Deserialize<'de> for RegistrationPayload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = Option::::deserialize(deserializer)?; - let raw_json = value - .map(|value| serde_json::to_string(&value).map_err(serde::de::Error::custom)) - .transpose()?; - - Ok(Self { raw_json }) - } -} - -/// Node metadata recorded before config materialization. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NodeRegistration { - pub identifier: String, - pub ip: Ipv4Addr, - #[serde(default, skip_serializing_if = "RegistrationPayload::is_empty")] - pub metadata: RegistrationPayload, -} - -impl NodeRegistration { - #[must_use] - pub fn new(identifier: impl Into, ip: Ipv4Addr) -> Self { - Self { - identifier: identifier.into(), - ip, - metadata: RegistrationPayload::default(), - } - } - - pub fn with_metadata(mut self, metadata: &T) -> Result - where - T: Serialize, - { - self.metadata = RegistrationPayload::from_serializable(metadata)?; - Ok(self) - } - - #[must_use] - pub fn with_payload(mut self, payload: RegistrationPayload) -> Self { - self.metadata = payload; - self - } -} - -impl NodeArtifactsPayload { - #[must_use] - pub fn from_files(files: Vec) -> Self { - Self { - schema_version: CFGSYNC_SCHEMA_VERSION, - files, - } - } - - #[must_use] - pub fn files(&self) -> &[NodeArtifactFile] { - &self.files - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.files.is_empty() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CfgsyncErrorCode { - MissingConfig, - NotReady, - Internal, -} - -/// Structured error body returned by cfgsync server. -#[derive(Debug, Clone, Serialize, Deserialize, Error)] -#[error("{code:?}: {message}")] -pub struct CfgsyncErrorResponse { - pub code: CfgsyncErrorCode, - pub message: String, -} - -impl CfgsyncErrorResponse { - #[must_use] - pub fn missing_config(identifier: &str) -> Self { - Self { - code: CfgsyncErrorCode::MissingConfig, - message: format!("missing config for host {identifier}"), - } - } - - #[must_use] - pub fn not_ready(identifier: &str) -> Self { - Self { - code: CfgsyncErrorCode::NotReady, - message: format!("config for host {identifier} is not ready"), - } - } - - #[must_use] - pub fn internal(message: impl Into) -> Self { - Self { - code: CfgsyncErrorCode::Internal, - message: message.into(), - } - } -} - -/// Resolution outcome for a requested node identifier. -pub enum ConfigResolveResponse { - Config(NodeArtifactsPayload), - Error(CfgsyncErrorResponse), -} - -/// Outcome for a node registration request. -pub enum RegisterNodeResponse { - Registered, - Error(CfgsyncErrorResponse), -} - -/// Source of cfgsync node payloads. -pub trait NodeConfigSource: Send + Sync { - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse; - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse; -} - -/// In-memory map-backed source used by cfgsync server state. -pub struct StaticConfigSource { - configs: HashMap, -} - -impl StaticConfigSource { - #[must_use] - pub fn from_bundle(configs: HashMap) -> Arc { - Arc::new(Self { configs }) - } -} - -impl NodeConfigSource for StaticConfigSource { - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { - if self.configs.contains_key(®istration.identifier) { - RegisterNodeResponse::Registered - } else { - RegisterNodeResponse::Error(CfgsyncErrorResponse::missing_config( - ®istration.identifier, - )) - } - } - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { - self.configs - .get(®istration.identifier) - .cloned() - .map_or_else( - || { - ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( - ®istration.identifier, - )) - }, - ConfigResolveResponse::Config, - ) - } -} - -#[derive(Debug, Error)] -pub enum BundleLoadError { - #[error("reading cfgsync bundle {path}: {source}")] - ReadBundle { - path: String, - #[source] - source: std::io::Error, - }, - #[error("parsing cfgsync bundle {path}: {source}")] - ParseBundle { - path: String, - #[source] - source: serde_yaml::Error, - }, -} - -#[must_use] -pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { - bundle - .nodes - .into_iter() - .map(|node| { - let NodeArtifactsBundleEntry { identifier, files } = node; - - (identifier, NodeArtifactsPayload::from_files(files)) - }) - .collect() -} - -pub fn load_bundle(path: &Path) -> Result { - let path_string = path.display().to_string(); - let raw = fs::read_to_string(path).map_err(|source| BundleLoadError::ReadBundle { - path: path_string.clone(), - source, - })?; - serde_yaml::from_str(&raw).map_err(|source| BundleLoadError::ParseBundle { - path: path_string, - source, - }) -} - -#[cfg(test)] -mod tests { - use std::io::Write as _; - - use tempfile::NamedTempFile; - - use super::*; - - #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] - struct ExampleRegistration { - network_port: u16, - service: String, - } - - #[test] - fn registration_payload_round_trips_typed_value() { - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")) - .with_metadata(&ExampleRegistration { - network_port: 3000, - service: "blend".to_owned(), - }) - .expect("serialize registration metadata"); - - let encoded = serde_json::to_value(®istration).expect("serialize registration"); - let metadata = encoded.get("metadata").expect("registration metadata"); - assert_eq!(metadata.get("network_port"), Some(&Value::from(3000u16))); - assert_eq!(metadata.get("service"), Some(&Value::from("blend"))); - - let decoded: NodeRegistration = - serde_json::from_value(encoded).expect("deserialize registration"); - let typed: ExampleRegistration = decoded - .metadata - .deserialize() - .expect("deserialize metadata") - .expect("registration metadata value"); - - assert_eq!(typed.network_port, 3000); - assert_eq!(typed.service, "blend"); - } - - fn sample_payload() -> NodeArtifactsPayload { - NodeArtifactsPayload::from_files(vec![NodeArtifactFile::new("/config.yaml", "key: value")]) - } - - #[test] - fn resolves_existing_identifier() { - let mut configs = HashMap::new(); - configs.insert("node-1".to_owned(), sample_payload()); - let repo = StaticConfigSource { configs }; - - match repo.resolve(&NodeRegistration::new( - "node-1", - "127.0.0.1".parse().expect("parse ip"), - )) { - ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); - assert_eq!(payload.files.len(), 1); - assert_eq!(payload.files[0].path, "/config.yaml"); - } - ConfigResolveResponse::Error(error) => panic!("expected config response, got {error}"), - } - } - - #[test] - fn reports_missing_identifier() { - let repo = StaticConfigSource { - configs: HashMap::new(), - }; - - match repo.resolve(&NodeRegistration::new( - "unknown-node", - "127.0.0.1".parse().expect("parse ip"), - )) { - ConfigResolveResponse::Config(_) => panic!("expected missing-config error"), - ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::MissingConfig)); - assert!(error.message.contains("unknown-node")); - } - } - } - - #[test] - fn loads_file_provider_bundle() { - let mut bundle_file = NamedTempFile::new().expect("create temp bundle"); - let yaml = r#" -nodes: - - identifier: node-1 - files: - - path: /config.yaml - content: "a: 1" -"#; - bundle_file - .write_all(yaml.as_bytes()) - .expect("write bundle yaml"); - - let provider = - BundleConfigSource::from_yaml_file(bundle_file.path()).expect("load file provider"); - - let _ = provider.register(NodeRegistration::new( - "node-1", - "127.0.0.1".parse().expect("parse ip"), - )); - - match provider.resolve(&NodeRegistration::new( - "node-1", - "127.0.0.1".parse().expect("parse ip"), - )) { - ConfigResolveResponse::Config(payload) => assert_eq!(payload.files.len(), 1), - ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), - } - } - - #[test] - fn resolve_accepts_known_registration_without_gating() { - let mut configs = HashMap::new(); - configs.insert("node-1".to_owned(), sample_payload()); - let repo = StaticConfigSource { configs }; - - match repo.resolve(&NodeRegistration::new( - "node-1", - "127.0.0.1".parse().expect("parse ip"), - )) { - ConfigResolveResponse::Config(_) => {} - ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), - } - } -} - -/// Failures when loading a bundle-backed cfgsync source. -#[derive(Debug, Error)] -pub enum BundleConfigSourceError { - #[error("failed to read cfgsync bundle at {path}: {source}")] - Read { - path: String, - #[source] - source: std::io::Error, - }, - #[error("failed to parse cfgsync bundle at {path}: {source}")] - Parse { - path: String, - #[source] - source: serde_yaml::Error, - }, -} - -/// YAML bundle-backed source implementation. -pub struct BundleConfigSource { - inner: StaticConfigSource, -} - -impl BundleConfigSource { - /// Loads provider state from a cfgsync bundle YAML file. - pub fn from_yaml_file(path: &Path) -> Result { - let raw = fs::read_to_string(path).map_err(|source| BundleConfigSourceError::Read { - path: path.display().to_string(), - source, - })?; - - let bundle: NodeArtifactsBundle = - serde_yaml::from_str(&raw).map_err(|source| BundleConfigSourceError::Parse { - path: path.display().to_string(), - source, - })?; - - let configs = bundle - .nodes - .into_iter() - .map(payload_from_bundle_node) - .collect(); - - Ok(Self { - inner: StaticConfigSource { configs }, - }) - } -} - -impl NodeConfigSource for BundleConfigSource { - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { - self.inner.register(registration) - } - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { - self.inner.resolve(registration) - } -} - -fn payload_from_bundle_node(node: NodeArtifactsBundleEntry) -> (String, NodeArtifactsPayload) { - ( - node.identifier, - NodeArtifactsPayload::from_files(node.files), - ) -} - -#[doc(hidden)] -pub type RepoResponse = ConfigResolveResponse; - -#[doc(hidden)] -pub type RegistrationResponse = RegisterNodeResponse; - -#[doc(hidden)] -pub trait ConfigProvider: NodeConfigSource {} - -impl ConfigProvider for T {} - -#[doc(hidden)] -pub type ConfigRepo = StaticConfigSource; - -#[doc(hidden)] -pub type FileConfigProvider = BundleConfigSource; - -#[doc(hidden)] -pub type FileConfigProviderError = BundleConfigSourceError; - -#[doc(hidden)] -pub type CfgSyncFile = NodeArtifactFile; - -#[doc(hidden)] -pub type CfgSyncPayload = NodeArtifactsPayload; - -#[doc(hidden)] -pub type CfgSyncErrorCode = CfgsyncErrorCode; - -#[doc(hidden)] -pub type CfgSyncErrorResponse = CfgsyncErrorResponse; diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 59970e1..3a82a17 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -3,7 +3,7 @@ use std::{io, sync::Arc}; use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; use thiserror::Error; -use crate::repo::{ +use crate::{ CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, RegisterNodeResponse, }; @@ -84,6 +84,14 @@ fn error_status(code: &CfgsyncErrorCode) -> StatusCode { } pub fn build_cfgsync_router(state: CfgsyncServerState) -> Router { + Router::new() + .route("/register", post(register_node)) + .route("/node", post(node_config)) + .with_state(Arc::new(state)) +} + +#[doc(hidden)] +pub fn build_legacy_cfgsync_router(state: CfgsyncServerState) -> Router { Router::new() .route("/register", post(register_node)) .route("/node", post(node_config)) @@ -108,14 +116,6 @@ pub async fn serve_cfgsync(port: u16, state: CfgsyncServerState) -> Result<(), R Ok(()) } -#[doc(hidden)] -pub type CfgSyncState = CfgsyncServerState; - -#[doc(hidden)] -pub use build_cfgsync_router as cfgsync_app; -#[doc(hidden)] -pub use serve_cfgsync as run_cfgsync; - #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; @@ -123,7 +123,7 @@ mod tests { use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use super::{CfgsyncServerState, NodeRegistration, node_config, register_node}; - use crate::repo::{ + use crate::{ CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, NodeConfigSource, RegisterNodeResponse, }; diff --git a/cfgsync/core/src/source.rs b/cfgsync/core/src/source.rs new file mode 100644 index 0000000..7981343 --- /dev/null +++ b/cfgsync/core/src/source.rs @@ -0,0 +1,264 @@ +use std::{collections::HashMap, fs, path::Path, sync::Arc}; + +use thiserror::Error; + +use crate::{ + NodeArtifactsBundle, NodeArtifactsBundleEntry, NodeArtifactsPayload, NodeRegistration, + RegisterNodeResponse, protocol::ConfigResolveResponse, +}; + +/// Source of cfgsync node payloads. +pub trait NodeConfigSource: Send + Sync { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse; + + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse; +} + +/// In-memory map-backed source used by cfgsync server state. +pub struct StaticConfigSource { + configs: HashMap, +} + +impl StaticConfigSource { + #[must_use] + pub fn from_payloads(configs: HashMap) -> Arc { + Arc::new(Self { configs }) + } + + #[must_use] + pub fn from_bundle(bundle: NodeArtifactsBundle) -> Arc { + Self::from_payloads(bundle_to_payload_map(bundle)) + } +} + +impl NodeConfigSource for StaticConfigSource { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { + if self.configs.contains_key(®istration.identifier) { + RegisterNodeResponse::Registered + } else { + RegisterNodeResponse::Error(crate::CfgsyncErrorResponse::missing_config( + ®istration.identifier, + )) + } + } + + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { + self.configs + .get(®istration.identifier) + .cloned() + .map_or_else( + || { + ConfigResolveResponse::Error(crate::CfgsyncErrorResponse::missing_config( + ®istration.identifier, + )) + }, + ConfigResolveResponse::Config, + ) + } +} + +#[derive(Debug, Error)] +pub enum BundleLoadError { + #[error("reading cfgsync bundle {path}: {source}")] + ReadBundle { + path: String, + #[source] + source: std::io::Error, + }, + #[error("parsing cfgsync bundle {path}: {source}")] + ParseBundle { + path: String, + #[source] + source: serde_yaml::Error, + }, +} + +#[must_use] +pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { + bundle + .nodes + .into_iter() + .map(|node| { + let NodeArtifactsBundleEntry { identifier, files } = node; + + (identifier, NodeArtifactsPayload::from_files(files)) + }) + .collect() +} + +pub fn load_bundle(path: &Path) -> Result { + let path_string = path.display().to_string(); + let raw = fs::read_to_string(path).map_err(|source| BundleLoadError::ReadBundle { + path: path_string.clone(), + source, + })?; + serde_yaml::from_str(&raw).map_err(|source| BundleLoadError::ParseBundle { + path: path_string, + source, + }) +} + +/// Failures when loading a bundle-backed cfgsync source. +#[derive(Debug, Error)] +pub enum BundleConfigSourceError { + #[error("failed to read cfgsync bundle at {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to parse cfgsync bundle at {path}: {source}")] + Parse { + path: String, + #[source] + source: serde_yaml::Error, + }, +} + +/// YAML bundle-backed source implementation. +pub struct BundleConfigSource { + inner: StaticConfigSource, +} + +impl BundleConfigSource { + /// Loads source state from a cfgsync bundle YAML file. + pub fn from_yaml_file(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| BundleConfigSourceError::Read { + path: path.display().to_string(), + source, + })?; + + let bundle: NodeArtifactsBundle = + serde_yaml::from_str(&raw).map_err(|source| BundleConfigSourceError::Parse { + path: path.display().to_string(), + source, + })?; + + let configs = bundle + .nodes + .into_iter() + .map(payload_from_bundle_node) + .collect(); + + Ok(Self { + inner: StaticConfigSource { configs }, + }) + } +} + +impl NodeConfigSource for BundleConfigSource { + fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { + self.inner.register(registration) + } + + fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { + self.inner.resolve(registration) + } +} + +fn payload_from_bundle_node(node: NodeArtifactsBundleEntry) -> (String, NodeArtifactsPayload) { + ( + node.identifier, + NodeArtifactsPayload::from_files(node.files), + ) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write as _}; + + use tempfile::NamedTempFile; + + use super::{BundleConfigSource, StaticConfigSource}; + use crate::{ + CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, ConfigResolveResponse, NodeArtifactFile, + NodeArtifactsPayload, NodeConfigSource, NodeRegistration, + }; + + fn sample_payload() -> NodeArtifactsPayload { + NodeArtifactsPayload::from_files(vec![NodeArtifactFile::new("/config.yaml", "key: value")]) + } + + #[test] + fn resolves_existing_identifier() { + let mut configs = HashMap::new(); + configs.insert("node-1".to_owned(), sample_payload()); + let repo = StaticConfigSource { configs }; + + match repo.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { + ConfigResolveResponse::Config(payload) => { + assert_eq!(payload.schema_version, CFGSYNC_SCHEMA_VERSION); + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].path, "/config.yaml"); + } + ConfigResolveResponse::Error(error) => panic!("expected config response, got {error}"), + } + } + + #[test] + fn reports_missing_identifier() { + let repo = StaticConfigSource { + configs: HashMap::new(), + }; + + match repo.resolve(&NodeRegistration::new( + "unknown-node", + "127.0.0.1".parse().expect("parse ip"), + )) { + ConfigResolveResponse::Config(_) => panic!("expected missing-config error"), + ConfigResolveResponse::Error(error) => { + assert!(matches!(error.code, CfgsyncErrorCode::MissingConfig)); + assert!(error.message.contains("unknown-node")); + } + } + } + + #[test] + fn loads_file_provider_bundle() { + let mut bundle_file = NamedTempFile::new().expect("create temp bundle"); + let yaml = r#" +nodes: + - identifier: node-1 + files: + - path: /config.yaml + content: "a: 1" +"#; + bundle_file + .write_all(yaml.as_bytes()) + .expect("write bundle yaml"); + + let provider = + BundleConfigSource::from_yaml_file(bundle_file.path()).expect("load file provider"); + + let _ = provider.register(NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )); + + match provider.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { + ConfigResolveResponse::Config(payload) => assert_eq!(payload.files.len(), 1), + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), + } + } + + #[test] + fn resolve_accepts_known_registration_without_gating() { + let mut configs = HashMap::new(); + configs.insert("node-1".to_owned(), sample_payload()); + let repo = StaticConfigSource { configs }; + + match repo.resolve(&NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )) { + ConfigResolveResponse::Config(_) => {} + ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), + } + } +} diff --git a/cfgsync/runtime/src/bin/cfgsync-server.rs b/cfgsync/runtime/src/bin/cfgsync-server.rs index 28134ea..a719044 100644 --- a/cfgsync/runtime/src/bin/cfgsync-server.rs +++ b/cfgsync/runtime/src/bin/cfgsync-server.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; -use cfgsync_runtime::run_cfgsync_server; +use cfgsync_runtime::serve_cfgsync_from_config; use clap::Parser; #[derive(Parser, Debug)] -#[command(about = "CfgSync")] +#[command(about = "Cfgsync server")] struct Args { config: PathBuf, } @@ -12,5 +12,5 @@ struct Args { #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Args::parse(); - run_cfgsync_server(&args.config).await + serve_cfgsync_from_config(&args.config).await } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 9951e3f..340bdc1 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -184,11 +184,9 @@ fn parse_registration_payload(raw: &str) -> Result { #[cfg(test)] mod tests { - use std::collections::HashMap; - use cfgsync_core::{ - CfgsyncServerState, NodeArtifactsBundle, NodeArtifactsBundleEntry, NodeArtifactsPayload, - StaticConfigSource, serve_cfgsync, + CfgsyncServerState, NodeArtifactsBundle, NodeArtifactsBundleEntry, StaticConfigSource, + serve_cfgsync, }; use tempfile::tempdir; @@ -208,7 +206,7 @@ mod tests { ], }]); - let repo = StaticConfigSource::from_bundle(bundle_to_payload_map(bundle)); + let repo = StaticConfigSource::from_bundle(bundle); let state = CfgsyncServerState::new(repo); let port = allocate_test_port(); let address = format!("http://127.0.0.1:{port}"); @@ -234,19 +232,6 @@ mod tests { assert_eq!(app_config, "app_key: app_value"); assert_eq!(deployment, "mode: local"); } - - fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { - bundle - .nodes - .into_iter() - .map(|node| { - let NodeArtifactsBundleEntry { identifier, files } = node; - - (identifier, NodeArtifactsPayload::from_files(files)) - }) - .collect() - } - fn allocate_test_port() -> u16 { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port for test"); diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index f9b225f..f74a8a4 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -4,8 +4,7 @@ mod client; mod server; pub use client::run_cfgsync_client_from_env; -#[doc(hidden)] -pub use server::CfgSyncServerConfig; pub use server::{ - CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, run_cfgsync_server, + CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, + serve_cfgsync_from_config, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index c77b05d..651df58 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -1,7 +1,7 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; -use cfgsync_adapter::{NodeArtifacts, NodeArtifactsCatalog, RegistrationConfigProvider}; +use cfgsync_adapter::{MaterializingConfigSource, NodeArtifacts, NodeArtifactsCatalog}; use cfgsync_core::{ BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, serve_cfgsync, }; @@ -25,16 +25,6 @@ pub enum CfgsyncServingMode { Registration, } -#[derive(Debug, Deserialize)] -struct RawCfgsyncServerConfig { - port: u16, - bundle_path: String, - #[serde(default)] - serving_mode: Option, - #[serde(default)] - registration_flow: Option, -} - #[derive(Debug, Error)] pub enum LoadCfgsyncServerConfigError { #[error("failed to read cfgsync config file {path}: {source}")] @@ -61,20 +51,11 @@ impl CfgsyncServerConfig { source, })?; - let raw: RawCfgsyncServerConfig = - serde_yaml::from_str(&config_content).map_err(|source| { - LoadCfgsyncServerConfigError::Parse { - path: config_path, - source, - } - })?; - - Ok(Self { - port: raw.port, - bundle_path: raw.bundle_path, - serving_mode: raw - .serving_mode - .unwrap_or_else(|| mode_from_legacy_registration_flow(raw.registration_flow)), + serde_yaml::from_str(&config_content).map_err(|source| { + LoadCfgsyncServerConfigError::Parse { + path: config_path, + source, + } }) } @@ -97,14 +78,6 @@ impl CfgsyncServerConfig { } } -fn mode_from_legacy_registration_flow(registration_flow: Option) -> CfgsyncServingMode { - if registration_flow.unwrap_or(false) { - CfgsyncServingMode::Registration - } else { - CfgsyncServingMode::Bundle - } -} - fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { let provider = BundleConfigSource::from_yaml_file(bundle_path) .with_context(|| format!("loading cfgsync provider from {}", bundle_path.display()))?; @@ -112,10 +85,10 @@ fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result> { +fn load_registration_source(bundle_path: &Path) -> anyhow::Result> { let bundle = load_bundle_yaml(bundle_path)?; let catalog = build_node_catalog(bundle); - let provider = RegistrationConfigProvider::new(catalog); + let provider = MaterializingConfigSource::new(catalog); Ok(Arc::new(provider)) } @@ -154,7 +127,7 @@ fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::Path } /// Loads runtime config and starts cfgsync HTTP server process. -pub async fn run_cfgsync_server(config_path: &Path) -> anyhow::Result<()> { +pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> { let config = CfgsyncServerConfig::load_from_file(config_path)?; let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); @@ -170,11 +143,8 @@ fn build_server_state( ) -> anyhow::Result { let repo = match config.serving_mode { CfgsyncServingMode::Bundle => load_bundle_provider(bundle_path)?, - CfgsyncServingMode::Registration => load_materializing_provider(bundle_path)?, + CfgsyncServingMode::Registration => load_registration_source(bundle_path)?, }; Ok(CfgsyncServerState::new(repo)) } - -#[doc(hidden)] -pub type CfgSyncServerConfig = CfgsyncServerConfig; diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 4d06cd6..9833e57 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use cfgsync_adapter::{CfgsyncEnv, build_cfgsync_node_catalog}; +use cfgsync_adapter::{DeploymentAdapter, build_node_artifact_catalog}; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::{ NodeArtifactsBundle, NodeArtifactsBundleEntry, @@ -27,7 +27,7 @@ enum BundleRenderError { MissingYamlKey { key: String }, } -pub(crate) fn render_cfgsync_from_template( +pub(crate) fn render_cfgsync_from_template( topology: &E::Deployment, hostnames: &[String], options: CfgsyncRenderOptions, @@ -45,11 +45,11 @@ pub(crate) fn render_cfgsync_from_template( }) } -fn build_cfgsync_bundle( +fn build_cfgsync_bundle( topology: &E::Deployment, hostnames: &[String], ) -> Result { - let nodes = build_cfgsync_node_catalog::(topology, hostnames)?.into_configs(); + let nodes = build_node_artifact_catalog::(topology, hostnames)?.into_nodes(); let nodes = nodes .into_iter() .map(|node| NodeArtifactsBundleEntry { @@ -129,7 +129,7 @@ fn build_cfgsync_server_config() -> Value { Value::Mapping(root) } -pub(crate) fn render_and_write_cfgsync_from_template( +pub(crate) fn render_and_write_cfgsync_from_template( topology: &E::Deployment, hostnames: &[String], mut options: CfgsyncRenderOptions, @@ -143,7 +143,7 @@ pub(crate) fn render_and_write_cfgsync_from_template( Ok(rendered) } -fn build_overrides( +fn build_overrides( topology: &E::Deployment, options: CfgsyncRenderOptions, ) -> CfgsyncConfigOverrides { diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index af45d4d..8ed5910 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -1 +1,5 @@ pub use cfgsync_adapter::*; +#[doc(hidden)] +pub use cfgsync_adapter::{ + DeploymentAdapter as CfgsyncEnv, build_node_artifact_catalog as build_cfgsync_node_catalog, +}; From 59e4f21bb187546e7cb861a6e1caacb068aa6ffe Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 13:56:27 +0100 Subject: [PATCH 15/38] Add reusable cfgsync runtime helpers --- Cargo.lock | 2 +- cfgsync/adapter/Cargo.toml | 1 + cfgsync/adapter/src/lib.rs | 3 +- cfgsync/adapter/src/materializer.rs | 66 ++++++++++++++- cfgsync/adapter/src/registrations.rs | 7 +- cfgsync/adapter/src/sources.rs | 47 ++++++++++- cfgsync/runtime/Cargo.toml | 1 - cfgsync/runtime/src/client.rs | 118 +++++++++++++++++++++------ cfgsync/runtime/src/lib.rs | 7 +- cfgsync/runtime/src/server.rs | 20 ++++- 10 files changed, 235 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2be427b..dbfd17a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ "cfgsync-artifacts", "cfgsync-core", "serde", + "serde_json", "thiserror 2.0.18", ] @@ -959,7 +960,6 @@ dependencies = [ "cfgsync-core", "clap", "serde", - "serde_json", "serde_yaml", "tempfile", "thiserror 2.0.18", diff --git a/cfgsync/adapter/Cargo.toml b/cfgsync/adapter/Cargo.toml index bcc4da7..cba0480 100644 --- a/cfgsync/adapter/Cargo.toml +++ b/cfgsync/adapter/Cargo.toml @@ -16,4 +16,5 @@ workspace = true cfgsync-artifacts = { workspace = true } cfgsync-core = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index c530f62..fd4e168 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -10,7 +10,8 @@ pub use deployment::{ build_node_artifact_catalog, }; pub use materializer::{ - DynCfgsyncError, NodeArtifactsMaterializer, RegistrationSnapshotMaterializer, + CachedSnapshotMaterializer, DynCfgsyncError, NodeArtifactsMaterializer, + RegistrationSnapshotMaterializer, }; pub use registrations::RegistrationSnapshot; pub use sources::{MaterializingConfigSource, SnapshotConfigSource}; diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index 5680960..764bcc3 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -1,6 +1,7 @@ -use std::error::Error; +use std::{error::Error, sync::Mutex}; use cfgsync_core::NodeRegistration; +use serde_json::to_string; use crate::{ArtifactSet, NodeArtifactsCatalog, RegistrationSnapshot}; @@ -24,3 +25,66 @@ pub trait RegistrationSnapshotMaterializer: Send + Sync { registrations: &RegistrationSnapshot, ) -> Result, DynCfgsyncError>; } + +/// Snapshot materializer wrapper that caches the last materialized result. +pub struct CachedSnapshotMaterializer { + inner: M, + cache: Mutex>, +} + +struct CachedSnapshot { + key: String, + catalog: Option, +} + +impl CachedSnapshotMaterializer { + #[must_use] + pub fn new(inner: M) -> Self { + Self { + inner, + cache: Mutex::new(None), + } + } + + fn snapshot_key(registrations: &RegistrationSnapshot) -> Result { + Ok(to_string(registrations)?) + } +} + +impl RegistrationSnapshotMaterializer for CachedSnapshotMaterializer +where + M: RegistrationSnapshotMaterializer, +{ + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + let key = Self::snapshot_key(registrations)?; + + { + let cache = self + .cache + .lock() + .expect("cfgsync snapshot cache should not be poisoned"); + + if let Some(cached) = &*cache + && cached.key == key + { + return Ok(cached.catalog.clone()); + } + } + + let catalog = self.inner.materialize_snapshot(registrations)?; + let mut cache = self + .cache + .lock() + .expect("cfgsync snapshot cache should not be poisoned"); + + *cache = Some(CachedSnapshot { + key, + catalog: catalog.clone(), + }); + + Ok(catalog) + } +} diff --git a/cfgsync/adapter/src/registrations.rs b/cfgsync/adapter/src/registrations.rs index 3926b57..47e365f 100644 --- a/cfgsync/adapter/src/registrations.rs +++ b/cfgsync/adapter/src/registrations.rs @@ -1,14 +1,17 @@ use cfgsync_core::NodeRegistration; +use serde::Serialize; /// Immutable view of registrations currently known to cfgsync. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize)] pub struct RegistrationSnapshot { registrations: Vec, } impl RegistrationSnapshot { #[must_use] - pub fn new(registrations: Vec) -> Self { + pub fn new(mut registrations: Vec) -> Self { + registrations.sort_by(|left, right| left.identifier.cmp(&right.identifier)); + Self { registrations } } diff --git a/cfgsync/adapter/src/sources.rs b/cfgsync/adapter/src/sources.rs index 5008515..e655a13 100644 --- a/cfgsync/adapter/src/sources.rs +++ b/cfgsync/adapter/src/sources.rs @@ -204,8 +204,8 @@ mod tests { use super::{MaterializingConfigSource, SnapshotConfigSource}; use crate::{ - DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, NodeArtifactsMaterializer, - RegistrationSnapshot, RegistrationSnapshotMaterializer, + CachedSnapshotMaterializer, DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, + NodeArtifactsMaterializer, RegistrationSnapshot, RegistrationSnapshotMaterializer, }; #[test] @@ -362,4 +362,47 @@ mod tests { ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } + + struct CountingSnapshotMaterializer { + calls: std::sync::Arc, + } + + impl RegistrationSnapshotMaterializer for CountingSnapshotMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + self.calls.fetch_add(1, Ordering::SeqCst); + + Ok(Some(NodeArtifactsCatalog::new( + registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new("/config.yaml", "cached: true")], + }) + .collect(), + ))) + } + } + + #[test] + fn cached_snapshot_materializer_reuses_previous_result() { + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let source = SnapshotConfigSource::new(CachedSnapshotMaterializer::new( + CountingSnapshotMaterializer { + calls: std::sync::Arc::clone(&calls), + }, + )); + let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); + let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + + let _ = source.register(node_a.clone()); + let _ = source.register(node_b.clone()); + + let _ = source.resolve(&node_a); + let _ = source.resolve(&node_b); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + } } diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index b6e7a5f..f708ba1 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -18,7 +18,6 @@ cfgsync-adapter = { workspace = true } cfgsync-core = { workspace = true } clap = { version = "4", features = ["derive"] } serde = { workspace = true } -serde_json = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 340bdc1..b693ad9 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, env, fs, net::Ipv4Addr, path::{Path, PathBuf}, @@ -16,6 +17,36 @@ use tracing::info; const FETCH_ATTEMPTS: usize = 5; const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); +/// Output routing for fetched artifact files. +#[derive(Debug, Clone, Default)] +pub struct ArtifactOutputMap { + routes: HashMap, +} + +impl ArtifactOutputMap { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn route( + mut self, + artifact_path: impl Into, + output_path: impl Into, + ) -> Self { + self.routes.insert(artifact_path.into(), output_path.into()); + self + } + + fn resolve_path(&self, file: &NodeArtifactFile) -> PathBuf { + self.routes + .get(&file.path) + .cloned() + .unwrap_or_else(|| PathBuf::from(&file.path)) + } +} + #[derive(Debug, Error)] enum ClientEnvError { #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] @@ -55,25 +86,6 @@ async fn fetch_once( Ok(response) } -async fn pull_config_files(payload: NodeRegistration, server_addr: &str) -> Result<()> { - register_node(&payload, server_addr).await?; - - let config = fetch_with_retry(&payload, server_addr) - .await - .context("fetching cfgsync node config")?; - ensure_schema_version(&config)?; - - let files = collect_payload_files(&config)?; - - for file in files { - write_cfgsync_file(file)?; - } - - info!(files = files.len(), "cfgsync files saved"); - - Ok(()) -} - async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result<()> { let client = CfgsyncClient::new(server_addr); @@ -98,6 +110,40 @@ async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result< unreachable!("cfgsync register loop always returns before exhausting attempts"); } +/// Registers a node and fetches its artifact payload from cfgsync. +pub async fn register_and_fetch_artifacts( + registration: &NodeRegistration, + server_addr: &str, +) -> Result { + register_node(registration, server_addr).await?; + + let payload = fetch_with_retry(registration, server_addr) + .await + .context("fetching cfgsync node config")?; + ensure_schema_version(&payload)?; + + Ok(payload) +} + +/// Registers a node, fetches its artifact payload, and writes the files using +/// the provided output routing policy. +pub async fn fetch_and_write_artifacts( + registration: &NodeRegistration, + server_addr: &str, + outputs: &ArtifactOutputMap, +) -> Result<()> { + let payload = register_and_fetch_artifacts(registration, server_addr).await?; + let files = collect_payload_files(&payload)?; + + for file in files { + write_cfgsync_file(file, outputs)?; + } + + info!(files = files.len(), "cfgsync files saved"); + + Ok(()) +} + fn ensure_schema_version(config: &NodeArtifactsPayload) -> Result<()> { if config.schema_version != CFGSYNC_SCHEMA_VERSION { bail!( @@ -118,8 +164,8 @@ fn collect_payload_files(config: &NodeArtifactsPayload) -> Result<&[NodeArtifact Ok(config.files()) } -fn write_cfgsync_file(file: &NodeArtifactFile) -> Result<()> { - let path = PathBuf::from(&file.path); +fn write_cfgsync_file(file: &NodeArtifactFile, outputs: &ArtifactOutputMap) -> Result<()> { + let path = outputs.resolve_path(file); ensure_parent_dir(&path)?; @@ -153,10 +199,12 @@ pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { let identifier = env::var("CFG_HOST_IDENTIFIER").unwrap_or_else(|_| "unidentified-node".to_owned()); let metadata = parse_registration_payload_env()?; + let outputs = build_output_map(); - pull_config_files( - NodeRegistration::new(identifier, ip).with_payload(metadata), + fetch_and_write_artifacts( + &NodeRegistration::new(identifier, ip).with_payload(metadata), &server_addr, + &outputs, ) .await } @@ -182,6 +230,25 @@ fn parse_registration_payload(raw: &str) -> Result { RegistrationPayload::from_json_str(raw).context("parsing CFG_REGISTRATION_METADATA_JSON") } +fn build_output_map() -> ArtifactOutputMap { + let mut outputs = ArtifactOutputMap::default(); + + if let Ok(path) = env::var("CFG_FILE_PATH") { + outputs = outputs + .route("/config.yaml", path.clone()) + .route("config.yaml", path); + } + + if let Ok(path) = env::var("CFG_DEPLOYMENT_PATH") { + outputs = outputs + .route("/deployment.yaml", path.clone()) + .route("deployment-settings.yaml", path.clone()) + .route("/deployment-settings.yaml", path); + } + + outputs +} + #[cfg(test)] mod tests { use cfgsync_core::{ @@ -216,9 +283,10 @@ mod tests { .expect("run cfgsync server"); }); - pull_config_files( - NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), + fetch_and_write_artifacts( + &NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), &address, + &ArtifactOutputMap::default(), ) .await .expect("pull config files"); diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index f74a8a4..8a7337f 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -3,8 +3,11 @@ pub use cfgsync_core as core; mod client; mod server; -pub use client::run_cfgsync_client_from_env; +pub use client::{ + ArtifactOutputMap, fetch_and_write_artifacts, register_and_fetch_artifacts, + run_cfgsync_client_from_env, +}; pub use server::{ CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, - serve_cfgsync_from_config, + serve_cfgsync_from_config, serve_snapshot_cfgsync, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 651df58..077c075 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -1,9 +1,13 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; -use cfgsync_adapter::{MaterializingConfigSource, NodeArtifacts, NodeArtifactsCatalog}; +use cfgsync_adapter::{ + CachedSnapshotMaterializer, MaterializingConfigSource, NodeArtifacts, NodeArtifactsCatalog, + RegistrationSnapshotMaterializer, SnapshotConfigSource, +}; use cfgsync_core::{ - BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, serve_cfgsync, + BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, RunCfgsyncError, + serve_cfgsync, }; use serde::Deserialize; use thiserror::Error; @@ -137,6 +141,18 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> Ok(()) } +/// Runs a registration-backed cfgsync server directly from a snapshot +/// materializer. +pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> +where + M: RegistrationSnapshotMaterializer + 'static, +{ + let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new(materializer)); + let state = CfgsyncServerState::new(Arc::new(provider)); + + serve_cfgsync(port, state).await +} + fn build_server_state( config: &CfgsyncServerConfig, bundle_path: &Path, From 592b4d6a4f8ba3216d7b5a36e02f84afb5020ad6 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 14:00:18 +0100 Subject: [PATCH 16/38] Document cfgsync library integration model --- cfgsync/README.md | 293 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 cfgsync/README.md diff --git a/cfgsync/README.md b/cfgsync/README.md new file mode 100644 index 0000000..28adeee --- /dev/null +++ b/cfgsync/README.md @@ -0,0 +1,293 @@ +# cfgsync + +`cfgsync` is a small library stack for node registration and config artifact delivery. + +It is designed for distributed test and bootstrap flows where nodes need to: +- register themselves with a config service +- wait until config is ready +- fetch one artifact payload containing the files they need +- write those files locally and continue startup + +The important boundary is: +- `cfgsync` owns transport, registration storage, polling, and artifact serving +- the application adapter owns readiness policy and artifact generation + +That keeps `cfgsync` generic while still supporting app-specific bootstrap logic. + +## Crates + +### `cfgsync-artifacts` +Data types for delivered files. + +Primary types: +- `ArtifactFile` +- `ArtifactSet` + +Use this crate when you only need to talk about files and file collections. + +### `cfgsync-core` +Protocol and server/client building blocks. + +Primary types: +- `NodeRegistration` +- `RegistrationPayload` +- `NodeArtifactsPayload` +- `CfgsyncClient` +- `NodeConfigSource` +- `StaticConfigSource` +- `BundleConfigSource` +- `CfgsyncServerState` +- `build_cfgsync_router(...)` +- `serve_cfgsync(...)` + +This crate defines the generic HTTP contract: +- `POST /register` +- `POST /node` + +Typical flow: +1. client registers a node +2. client requests its artifacts +3. server returns either: + - `Ready` payload + - `NotReady` + - `Missing` + +### `cfgsync-adapter` +Adapter-facing materialization layer. + +Primary types: +- `NodeArtifacts` +- `NodeArtifactsCatalog` +- `RegistrationSnapshot` +- `NodeArtifactsMaterializer` +- `RegistrationSnapshotMaterializer` +- `CachedSnapshotMaterializer` +- `MaterializingConfigSource` +- `SnapshotConfigSource` +- `DeploymentAdapter` + +This crate is where app-specific bootstrap logic plugs in. + +Two useful patterns exist: +- single-node materialization + - `NodeArtifactsMaterializer` +- whole-snapshot materialization + - `RegistrationSnapshotMaterializer` + +Use snapshot materialization when readiness depends on the full registered set. + +### `cfgsync-runtime` +Small runtime helpers and binaries. + +Primary exports: +- `ArtifactOutputMap` +- `register_and_fetch_artifacts(...)` +- `fetch_and_write_artifacts(...)` +- `run_cfgsync_client_from_env()` +- `CfgsyncServerConfig` +- `CfgsyncServingMode` +- `serve_cfgsync_from_config(...)` +- `serve_snapshot_cfgsync(...)` + +This crate is for operational wiring, not for app-specific logic. + +## Design + +There are two serving models. + +### 1. Static bundle serving +Config is precomputed up front. + +Use: +- `NodeArtifactsBundle` +- `BundleConfigSource` +- `CfgsyncServingMode::Bundle` + +This is the simplest path when the full artifact set is already known. + +### 2. Registration-backed serving +Config is produced from node registrations. + +Use: +- `RegistrationSnapshotMaterializer` +- `CachedSnapshotMaterializer` +- `SnapshotConfigSource` +- `serve_snapshot_cfgsync(...)` + +This is the right model when config readiness depends on the current registered set. + +## Public API shape + +### Register a node + +Nodes register with: +- stable identifier +- IPv4 address +- optional typed application metadata + +Application metadata is carried as an opaque serialized payload: +- generic in `cfgsync` +- interpreted only by the adapter + +Example: + +```rust +use cfgsync_core::NodeRegistration; + +#[derive(serde::Serialize)] +struct MyNodeMetadata { + network_port: u16, + api_port: u16, +} + +let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().unwrap()) + .with_metadata(&MyNodeMetadata { + network_port: 3000, + api_port: 18080, + })?; +``` + +### Materialize from the registration snapshot + +```rust +use cfgsync_adapter::{ + DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, RegistrationSnapshot, + RegistrationSnapshotMaterializer, +}; +use cfgsync_artifacts::ArtifactFile; + +struct MyMaterializer; + +impl RegistrationSnapshotMaterializer for MyMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + if registrations.len() < 2 { + return Ok(None); + } + + let nodes = registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new( + "/config.yaml", + format!("id: {}\n", registration.identifier), + )], + }) + .collect(); + + Ok(Some(NodeArtifactsCatalog::new(nodes))) + } +} +``` + +### Serve registration-backed cfgsync + +```rust +use cfgsync_runtime::serve_snapshot_cfgsync; + +# async fn run() -> anyhow::Result<()> { +serve_snapshot_cfgsync(4400, MyMaterializer).await?; +# Ok(()) +# } +``` + +### Fetch from a client + +```rust +use cfgsync_core::CfgsyncClient; + +# async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { +let client = CfgsyncClient::new("http://127.0.0.1:4400"); +client.register_node(®istration).await?; +let payload = client.fetch_node_config(®istration).await?; +# Ok(()) +# } +``` + +### Fetch and write artifacts with runtime helpers + +```rust +use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts}; + +# async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { +let outputs = ArtifactOutputMap::new() + .route("/config.yaml", "/node-data/node-1/config.yaml") + .route("deployment-settings.yaml", "/node-data/shared/deployment-settings.yaml"); + +fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).await?; +# Ok(()) +# } +``` + +## What belongs in the adapter + +Put these in your app adapter: +- registration payload type +- readiness rule +- conversion from registration snapshot to artifacts +- shared file generation if your app needs shared files + +Examples: +- wait for `n` initial nodes +- derive peer lists from registrations +- build node-local config files +- include shared deployment/config files in every node payload + +## What does **not** belong in `cfgsync-core` + +Do not put these into generic cfgsync: +- app-specific topology rules +- domain-specific genesis/deployment generation +- app-specific command/state-machine logic +- service-specific semantics for what a node means + +Those belong in the adapter or the consuming application. + +## Recommended integration model + +If you are integrating a new app, start here: + +1. define a typed registration payload +2. implement `RegistrationSnapshotMaterializer` +3. return one artifact payload per node +4. include shared files inside that payload if your app needs them +5. serve with `serve_snapshot_cfgsync(...)` +6. use `CfgsyncClient` on the node side +7. use runtime helpers if you want generic client-side file writing instead of custom dispatch code + +This model keeps the generic library small and keeps application semantics where they belong. + +## Compatibility + +The primary surface is the one reexported from crate roots. + +There are hidden compatibility aliases in some crates to keep older internal consumers building, but they are not the recommended API for new integrations. + +## Runtime config files + +`serve_cfgsync_from_config(...)` is for runtime-config-driven serving. + +Today it supports: +- static bundle serving +- registration serving from a prebuilt artifact catalog + +If your app has a real registration-backed materializer, prefer the direct runtime API: +- `serve_snapshot_cfgsync(...)` + +That keeps application behavior in adapter code instead of trying to encode it into YAML. + +## Current status + +`cfgsync` is suitable for: +- internal reuse across multiple apps +- registration-backed bootstrap flows +- static precomputed artifact serving + +It is not intended to be: +- a generic orchestration framework +- a topology engine +- a secret-management system +- an app-specific bootstrap policy layer From 8681117301a1133c94093e2138768e0cc9be821f Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 14:24:00 +0100 Subject: [PATCH 17/38] Add shared cfgsync artifact materialization --- cfgsync/adapter/src/artifacts.rs | 73 ++++++++++++++++ cfgsync/adapter/src/lib.rs | 6 +- cfgsync/adapter/src/materializer.rs | 33 +++++-- cfgsync/adapter/src/sources.rs | 129 ++++++++++++++++++---------- cfgsync/core/src/bundle.rs | 13 ++- cfgsync/core/src/source.rs | 7 +- cfgsync/runtime/src/server.rs | 15 ++-- 7 files changed, 212 insertions(+), 64 deletions(-) diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs index 4ad2693..2418ade 100644 --- a/cfgsync/adapter/src/artifacts.rs +++ b/cfgsync/adapter/src/artifacts.rs @@ -33,6 +33,43 @@ impl ArtifactSet { pub fn is_empty(&self) -> bool { self.files.is_empty() } + + #[must_use] + pub fn into_files(self) -> Vec { + self.files + } +} + +/// Resolved artifact payload for one node, including any shared files that +/// should be delivered alongside the node-local files. +#[derive(Debug, Clone, Default)] +pub struct ResolvedNodeArtifacts { + node: ArtifactSet, + shared: ArtifactSet, +} + +impl ResolvedNodeArtifacts { + #[must_use] + pub fn new(node: ArtifactSet, shared: ArtifactSet) -> Self { + Self { node, shared } + } + + #[must_use] + pub fn node(&self) -> &ArtifactSet { + &self.node + } + + #[must_use] + pub fn shared(&self) -> &ArtifactSet { + &self.shared + } + + #[must_use] + pub fn files(&self) -> Vec { + let mut files = self.node.files().to_vec(); + files.extend_from_slice(self.shared.files()); + files + } } /// Artifact payloads indexed by stable node identifier. @@ -72,3 +109,39 @@ impl NodeArtifactsCatalog { self.nodes.into_values().collect() } } + +/// Materialized cfgsync output for a whole registration set. +#[derive(Debug, Clone, Default)] +pub struct MaterializedArtifacts { + nodes: NodeArtifactsCatalog, + shared: ArtifactSet, +} + +impl MaterializedArtifacts { + #[must_use] + pub fn new(nodes: NodeArtifactsCatalog, shared: ArtifactSet) -> Self { + Self { nodes, shared } + } + + #[must_use] + pub fn from_catalog(nodes: NodeArtifactsCatalog) -> Self { + Self::new(nodes, ArtifactSet::default()) + } + + #[must_use] + pub fn nodes(&self) -> &NodeArtifactsCatalog { + &self.nodes + } + + #[must_use] + pub fn shared(&self) -> &ArtifactSet { + &self.shared + } + + #[must_use] + pub fn resolve(&self, identifier: &str) -> Option { + self.nodes.resolve(identifier).map(|node| { + ResolvedNodeArtifacts::new(ArtifactSet::new(node.files.clone()), self.shared.clone()) + }) + } +} diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index fd4e168..9c172a9 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -4,13 +4,15 @@ mod materializer; mod registrations; mod sources; -pub use artifacts::{ArtifactSet, NodeArtifacts, NodeArtifactsCatalog}; +pub use artifacts::{ + ArtifactSet, MaterializedArtifacts, NodeArtifacts, NodeArtifactsCatalog, ResolvedNodeArtifacts, +}; pub use deployment::{ BuildCfgsyncNodesError, DeploymentAdapter, build_cfgsync_node_configs, build_node_artifact_catalog, }; pub use materializer::{ - CachedSnapshotMaterializer, DynCfgsyncError, NodeArtifactsMaterializer, + CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, NodeArtifactsMaterializer, RegistrationSnapshotMaterializer, }; pub use registrations::RegistrationSnapshot; diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index 764bcc3..02d7448 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -3,7 +3,7 @@ use std::{error::Error, sync::Mutex}; use cfgsync_core::NodeRegistration; use serde_json::to_string; -use crate::{ArtifactSet, NodeArtifactsCatalog, RegistrationSnapshot}; +use crate::{MaterializedArtifacts, RegistrationSnapshot, ResolvedNodeArtifacts}; /// Type-erased cfgsync adapter error used to preserve source context. pub type DynCfgsyncError = Box; @@ -14,7 +14,7 @@ pub trait NodeArtifactsMaterializer: Send + Sync { &self, registration: &NodeRegistration, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError>; + ) -> Result, DynCfgsyncError>; } /// Adapter contract for materializing a whole registration snapshot into @@ -23,7 +23,22 @@ pub trait RegistrationSnapshotMaterializer: Send + Sync { fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError>; + ) -> Result; +} + +/// Registration-driven materialization status. +#[derive(Debug, Clone, Default)] +pub enum MaterializationResult { + #[default] + NotReady, + Ready(MaterializedArtifacts), +} + +impl MaterializationResult { + #[must_use] + pub fn ready(nodes: MaterializedArtifacts) -> Self { + Self::Ready(nodes) + } } /// Snapshot materializer wrapper that caches the last materialized result. @@ -34,7 +49,7 @@ pub struct CachedSnapshotMaterializer { struct CachedSnapshot { key: String, - catalog: Option, + result: MaterializationResult, } impl CachedSnapshotMaterializer { @@ -58,7 +73,7 @@ where fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + ) -> Result { let key = Self::snapshot_key(registrations)?; { @@ -70,11 +85,11 @@ where if let Some(cached) = &*cache && cached.key == key { - return Ok(cached.catalog.clone()); + return Ok(cached.result.clone()); } } - let catalog = self.inner.materialize_snapshot(registrations)?; + let result = self.inner.materialize_snapshot(registrations)?; let mut cache = self .cache .lock() @@ -82,9 +97,9 @@ where *cache = Some(CachedSnapshot { key, - catalog: catalog.clone(), + result: result.clone(), }); - Ok(catalog) + Ok(result) } } diff --git a/cfgsync/adapter/src/sources.rs b/cfgsync/adapter/src/sources.rs index e655a13..4e4c1a4 100644 --- a/cfgsync/adapter/src/sources.rs +++ b/cfgsync/adapter/src/sources.rs @@ -6,8 +6,9 @@ use cfgsync_core::{ }; use crate::{ - ArtifactSet, DynCfgsyncError, NodeArtifactsCatalog, NodeArtifactsMaterializer, - RegistrationSnapshot, RegistrationSnapshotMaterializer, + ArtifactSet, DynCfgsyncError, MaterializationResult, MaterializedArtifacts, + NodeArtifactsCatalog, NodeArtifactsMaterializer, RegistrationSnapshot, + RegistrationSnapshotMaterializer, ResolvedNodeArtifacts, }; impl NodeArtifactsMaterializer for NodeArtifactsCatalog { @@ -15,10 +16,13 @@ impl NodeArtifactsMaterializer for NodeArtifactsCatalog { &self, registration: &NodeRegistration, _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { - Ok(self - .resolve(®istration.identifier) - .map(build_artifact_set_from_catalog_entry)) + ) -> Result, DynCfgsyncError> { + Ok(self.resolve(®istration.identifier).map(|artifacts| { + ResolvedNodeArtifacts::new( + build_artifact_set_from_catalog_entry(artifacts), + ArtifactSet::default(), + ) + })) } } @@ -26,8 +30,29 @@ impl RegistrationSnapshotMaterializer for NodeArtifactsCatalog { fn materialize_snapshot( &self, _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { - Ok(Some(self.clone())) + ) -> Result { + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_catalog(self.clone()), + )) + } +} + +impl NodeArtifactsMaterializer for MaterializedArtifacts { + fn materialize( + &self, + registration: &NodeRegistration, + _registrations: &RegistrationSnapshot, + ) -> Result, DynCfgsyncError> { + Ok(self.resolve(®istration.identifier)) + } +} + +impl RegistrationSnapshotMaterializer for MaterializedArtifacts { + fn materialize_snapshot( + &self, + _registrations: &RegistrationSnapshot, + ) -> Result { + Ok(MaterializationResult::ready(self.clone())) } } @@ -91,9 +116,9 @@ where let registrations = self.registration_snapshot(); match self.materializer.materialize(®istration, ®istrations) { - Ok(Some(artifacts)) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( - artifacts.files().to_vec(), - )), + Ok(Some(artifacts)) => { + ConfigResolveResponse::Config(NodeArtifactsPayload::from_files(artifacts.files())) + } Ok(None) => ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )), @@ -164,9 +189,9 @@ where }; let registrations = self.registration_snapshot(); - let catalog = match self.materializer.materialize_snapshot(®istrations) { - Ok(Some(catalog)) => catalog, - Ok(None) => { + let materialized = match self.materializer.materialize_snapshot(®istrations) { + Ok(MaterializationResult::Ready(materialized)) => materialized, + Ok(MaterializationResult::NotReady) => { return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( ®istration.identifier, )); @@ -178,10 +203,10 @@ where } }; - match catalog.resolve(®istration.identifier) { - Some(config) => ConfigResolveResponse::Config(NodeArtifactsPayload::from_files( - config.files.clone(), - )), + match materialized.resolve(®istration.identifier) { + Some(config) => { + ConfigResolveResponse::Config(NodeArtifactsPayload::from_files(config.files())) + } None => ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, )), @@ -204,8 +229,9 @@ mod tests { use super::{MaterializingConfigSource, SnapshotConfigSource}; use crate::{ - CachedSnapshotMaterializer, DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, - NodeArtifactsMaterializer, RegistrationSnapshot, RegistrationSnapshotMaterializer, + ArtifactSet, CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, + MaterializedArtifacts, NodeArtifacts, NodeArtifactsCatalog, NodeArtifactsMaterializer, + RegistrationSnapshot, RegistrationSnapshotMaterializer, ResolvedNodeArtifacts, }; #[test] @@ -265,7 +291,7 @@ mod tests { &self, registration: &NodeRegistration, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + ) -> Result, DynCfgsyncError> { self.calls.fetch_add(1, Ordering::SeqCst); if registrations.len() < 2 { @@ -278,7 +304,10 @@ mod tests { ArtifactFile::new("/peers.txt", peer_count.to_string()), ]; - Ok(Some(crate::ArtifactSet::new(files))) + Ok(Some(ResolvedNodeArtifacts::new( + crate::ArtifactSet::new(files), + ArtifactSet::default(), + ))) } } @@ -318,22 +347,29 @@ mod tests { fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + ) -> Result { if registrations.len() < 2 { - return Ok(None); + return Ok(MaterializationResult::NotReady); } - Ok(Some(NodeArtifactsCatalog::new( - registrations - .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new( - "/config.yaml", - format!("peer_count: {}", registrations.len()), - )], - }) - .collect(), + let nodes = registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new( + "/config.yaml", + format!("peer_count: {}", registrations.len()), + )], + }) + .collect(); + let shared = ArtifactSet::new(vec![ArtifactFile::new( + "/shared.txt", + format!("shared_count: {}", registrations.len()), + )]); + + Ok(MaterializationResult::ready(MaterializedArtifacts::new( + NodeArtifactsCatalog::new(nodes), + shared, ))) } } @@ -358,6 +394,7 @@ mod tests { match source.resolve(&node_a) { ConfigResolveResponse::Config(payload) => { assert_eq!(payload.files()[0].content, "peer_count: 2"); + assert_eq!(payload.files()[1].content, "shared_count: 2"); } ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } @@ -371,18 +408,20 @@ mod tests { fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + ) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); - Ok(Some(NodeArtifactsCatalog::new( - registrations - .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new("/config.yaml", "cached: true")], - }) - .collect(), - ))) + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_catalog(NodeArtifactsCatalog::new( + registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new("/config.yaml", "cached: true")], + }) + .collect(), + )), + )) } } diff --git a/cfgsync/core/src/bundle.rs b/cfgsync/core/src/bundle.rs index 5281b13..31f6723 100644 --- a/cfgsync/core/src/bundle.rs +++ b/cfgsync/core/src/bundle.rs @@ -6,12 +6,23 @@ use crate::NodeArtifactFile; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeArtifactsBundle { pub nodes: Vec, + #[serde(default)] + pub shared_files: Vec, } impl NodeArtifactsBundle { #[must_use] pub fn new(nodes: Vec) -> Self { - Self { nodes } + Self { + nodes, + shared_files: Vec::new(), + } + } + + #[must_use] + pub fn with_shared_files(mut self, shared_files: Vec) -> Self { + self.shared_files = shared_files; + self } } diff --git a/cfgsync/core/src/source.rs b/cfgsync/core/src/source.rs index 7981343..9d00d9e 100644 --- a/cfgsync/core/src/source.rs +++ b/cfgsync/core/src/source.rs @@ -75,13 +75,18 @@ pub enum BundleLoadError { #[must_use] pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { + let shared_files = bundle.shared_files; + bundle .nodes .into_iter() .map(|node| { let NodeArtifactsBundleEntry { identifier, files } = node; - (identifier, NodeArtifactsPayload::from_files(files)) + let mut payload_files = files; + payload_files.extend(shared_files.clone()); + + (identifier, NodeArtifactsPayload::from_files(payload_files)) }) .collect() } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 077c075..6bae042 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -2,7 +2,7 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; use cfgsync_adapter::{ - CachedSnapshotMaterializer, MaterializingConfigSource, NodeArtifacts, NodeArtifactsCatalog, + ArtifactSet, CachedSnapshotMaterializer, MaterializedArtifacts, RegistrationSnapshotMaterializer, SnapshotConfigSource, }; use cfgsync_core::{ @@ -91,8 +91,8 @@ fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result> { let bundle = load_bundle_yaml(bundle_path)?; - let catalog = build_node_catalog(bundle); - let provider = MaterializingConfigSource::new(catalog); + let materialized = build_materialized_artifacts(bundle); + let provider = SnapshotConfigSource::new(materialized); Ok(Arc::new(provider)) } @@ -105,17 +105,20 @@ fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { .with_context(|| format!("parsing cfgsync bundle from {}", bundle_path.display())) } -fn build_node_catalog(bundle: NodeArtifactsBundle) -> NodeArtifactsCatalog { +fn build_materialized_artifacts(bundle: NodeArtifactsBundle) -> MaterializedArtifacts { let nodes = bundle .nodes .into_iter() - .map(|node| NodeArtifacts { + .map(|node| cfgsync_adapter::NodeArtifacts { identifier: node.identifier, files: node.files, }) .collect(); - NodeArtifactsCatalog::new(nodes) + MaterializedArtifacts::new( + cfgsync_adapter::NodeArtifactsCatalog::new(nodes), + ArtifactSet::new(bundle.shared_files), + ) } fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { From 95d1a751162774d059c1b0dab65ae168da99698a Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 14:26:00 +0100 Subject: [PATCH 18/38] Add cfgsync persistence and shared artifact hooks --- cfgsync/adapter/src/lib.rs | 4 +- cfgsync/adapter/src/materializer.rs | 154 ++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 9c172a9..0ddf349 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -12,8 +12,8 @@ pub use deployment::{ build_node_artifact_catalog, }; pub use materializer::{ - CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, NodeArtifactsMaterializer, - RegistrationSnapshotMaterializer, + CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifactsSink, + NodeArtifactsMaterializer, PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, }; pub use registrations::RegistrationSnapshot; pub use sources::{MaterializingConfigSource, SnapshotConfigSource}; diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index 02d7448..b2d18de 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -26,6 +26,11 @@ pub trait RegistrationSnapshotMaterializer: Send + Sync { ) -> Result; } +/// Optional hook for persisting or publishing materialized cfgsync artifacts. +pub trait MaterializedArtifactsSink: Send + Sync { + fn persist(&self, artifacts: &MaterializedArtifacts) -> Result<(), DynCfgsyncError>; +} + /// Registration-driven materialization status. #[derive(Debug, Clone, Default)] pub enum MaterializationResult { @@ -39,6 +44,14 @@ impl MaterializationResult { pub fn ready(nodes: MaterializedArtifacts) -> Self { Self::Ready(nodes) } + + #[must_use] + pub fn artifacts(&self) -> Option<&MaterializedArtifacts> { + match self { + Self::NotReady => None, + Self::Ready(artifacts) => Some(artifacts), + } + } } /// Snapshot materializer wrapper that caches the last materialized result. @@ -103,3 +116,144 @@ where Ok(result) } } + +/// Snapshot materializer wrapper that persists ready results through a generic +/// sink. It only persists once per distinct registration snapshot. +pub struct PersistingSnapshotMaterializer { + inner: M, + sink: S, + persisted_key: Mutex>, +} + +impl PersistingSnapshotMaterializer { + #[must_use] + pub fn new(inner: M, sink: S) -> Self { + Self { + inner, + sink, + persisted_key: Mutex::new(None), + } + } +} + +impl RegistrationSnapshotMaterializer for PersistingSnapshotMaterializer +where + M: RegistrationSnapshotMaterializer, + S: MaterializedArtifactsSink, +{ + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result { + let key = CachedSnapshotMaterializer::::snapshot_key(registrations)?; + let result = self.inner.materialize_snapshot(registrations)?; + + let Some(artifacts) = result.artifacts() else { + return Ok(result); + }; + + { + let persisted_key = self + .persisted_key + .lock() + .expect("cfgsync persistence state should not be poisoned"); + + if persisted_key.as_deref() == Some(&key) { + return Ok(result); + } + } + + self.sink.persist(artifacts)?; + + let mut persisted_key = self + .persisted_key + .lock() + .expect("cfgsync persistence state should not be poisoned"); + *persisted_key = Some(key); + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use cfgsync_artifacts::ArtifactFile; + + use super::{ + CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifacts, + MaterializedArtifactsSink, PersistingSnapshotMaterializer, + RegistrationSnapshotMaterializer, + }; + use crate::{ArtifactSet, NodeArtifacts, NodeArtifactsCatalog, RegistrationSnapshot}; + + struct CountingMaterializer; + + impl RegistrationSnapshotMaterializer for CountingMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result { + if registrations.is_empty() { + return Ok(MaterializationResult::NotReady); + } + + let nodes = registrations + .iter() + .map(|registration| NodeArtifacts { + identifier: registration.identifier.clone(), + files: vec![ArtifactFile::new("/config.yaml", "ready: true")], + }) + .collect(); + + Ok(MaterializationResult::ready(MaterializedArtifacts::new( + NodeArtifactsCatalog::new(nodes), + ArtifactSet::new(vec![ArtifactFile::new("/shared.yaml", "cluster: ready")]), + ))) + } + } + + struct CountingSink { + writes: Arc, + } + + impl MaterializedArtifactsSink for CountingSink { + fn persist(&self, _artifacts: &MaterializedArtifacts) -> Result<(), DynCfgsyncError> { + self.writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn persisting_snapshot_materializer_writes_ready_snapshots_once() { + let writes = Arc::new(AtomicUsize::new(0)); + let materializer = CachedSnapshotMaterializer::new(PersistingSnapshotMaterializer::new( + CountingMaterializer, + CountingSink { + writes: Arc::clone(&writes), + }, + )); + + let empty = RegistrationSnapshot::default(); + let ready = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( + "node-0", + "127.0.0.1".parse().expect("parse ip"), + )]); + + let _ = materializer + .materialize_snapshot(&empty) + .expect("not-ready snapshot"); + let _ = materializer + .materialize_snapshot(&ready) + .expect("ready snapshot"); + let _ = materializer + .materialize_snapshot(&ready) + .expect("cached ready snapshot"); + + assert_eq!(writes.load(Ordering::SeqCst), 1); + } +} From daadbcfa15f0ae1dee0fc36777f32c0689cb89e8 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 14:28:19 +0100 Subject: [PATCH 19/38] Add direct cfgsync materializer serving --- Cargo.lock | 1 + cfgsync/runtime/Cargo.toml | 1 + cfgsync/runtime/src/lib.rs | 3 +- cfgsync/runtime/src/server.rs | 63 +++++++++++++++++++++++++++++++---- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbfd17a..4b740bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -956,6 +956,7 @@ name = "cfgsync-runtime" version = "0.1.0" dependencies = [ "anyhow", + "axum", "cfgsync-adapter", "cfgsync-core", "clap", diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index f708ba1..547b182 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = "1" +axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } cfgsync-adapter = { workspace = true } cfgsync-core = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index 8a7337f..92c0192 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -9,5 +9,6 @@ pub use client::{ }; pub use server::{ CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, - serve_cfgsync_from_config, serve_snapshot_cfgsync, + build_persisted_snapshot_cfgsync_router, build_snapshot_cfgsync_router, + serve_cfgsync_from_config, serve_persisted_snapshot_cfgsync, serve_snapshot_cfgsync, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 6bae042..d9beda2 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -1,13 +1,14 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; +use axum::Router; use cfgsync_adapter::{ - ArtifactSet, CachedSnapshotMaterializer, MaterializedArtifacts, - RegistrationSnapshotMaterializer, SnapshotConfigSource, + ArtifactSet, CachedSnapshotMaterializer, MaterializedArtifacts, MaterializedArtifactsSink, + PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, SnapshotConfigSource, }; use cfgsync_core::{ BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, RunCfgsyncError, - serve_cfgsync, + build_cfgsync_router, serve_cfgsync, }; use serde::Deserialize; use thiserror::Error; @@ -144,16 +145,66 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> Ok(()) } +/// Builds a registration-backed cfgsync router directly from a snapshot +/// materializer. +pub fn build_snapshot_cfgsync_router(materializer: M) -> Router +where + M: RegistrationSnapshotMaterializer + 'static, +{ + let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new(materializer)); + build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) +} + +/// Builds a registration-backed cfgsync router with a persistence hook for +/// ready materialization results. +pub fn build_persisted_snapshot_cfgsync_router(materializer: M, sink: S) -> Router +where + M: RegistrationSnapshotMaterializer + 'static, + S: MaterializedArtifactsSink + 'static, +{ + let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new( + PersistingSnapshotMaterializer::new(materializer, sink), + )); + + build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) +} + /// Runs a registration-backed cfgsync server directly from a snapshot /// materializer. pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> where M: RegistrationSnapshotMaterializer + 'static, { - let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new(materializer)); - let state = CfgsyncServerState::new(Arc::new(provider)); + let router = build_snapshot_cfgsync_router(materializer); + serve_router(port, router).await +} - serve_cfgsync(port, state).await +/// Runs a registration-backed cfgsync server with a persistence hook for ready +/// materialization results. +pub async fn serve_persisted_snapshot_cfgsync( + port: u16, + materializer: M, + sink: S, +) -> Result<(), RunCfgsyncError> +where + M: RegistrationSnapshotMaterializer + 'static, + S: MaterializedArtifactsSink + 'static, +{ + let router = build_persisted_snapshot_cfgsync_router(materializer, sink); + serve_router(port, router).await +} + +async fn serve_router(port: u16, router: Router) -> Result<(), RunCfgsyncError> { + 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, router) + .await + .map_err(|source| RunCfgsyncError::Serve { source })?; + + Ok(()) } fn build_server_state( From 5e9b59140dbdb484b4442a81bfefe028555b1df3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Tue, 10 Mar 2026 14:44:28 +0100 Subject: [PATCH 20/38] Make cfgsync runtime source modes explicit --- cfgsync/runtime/src/lib.rs | 2 +- cfgsync/runtime/src/server.rs | 97 +++++++++++++++++++++------- logos/runtime/ext/src/cfgsync/mod.rs | 12 ++-- 3 files changed, 82 insertions(+), 29 deletions(-) diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index 92c0192..d51807c 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -8,7 +8,7 @@ pub use client::{ run_cfgsync_client_from_env, }; pub use server::{ - CfgsyncServerConfig, CfgsyncServingMode, LoadCfgsyncServerConfigError, + CfgsyncServerConfig, CfgsyncServerSource, LoadCfgsyncServerConfigError, build_persisted_snapshot_cfgsync_router, build_snapshot_cfgsync_router, serve_cfgsync_from_config, serve_persisted_snapshot_cfgsync, serve_snapshot_cfgsync, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index d9beda2..cfefd78 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -10,26 +10,38 @@ use cfgsync_core::{ BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, RunCfgsyncError, build_cfgsync_router, serve_cfgsync, }; -use serde::Deserialize; +use serde::{Deserialize, de::Error as _}; use thiserror::Error; /// Runtime cfgsync server config loaded from YAML. -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CfgsyncServerConfig { pub port: u16, - pub bundle_path: String, - #[serde(default)] - pub serving_mode: CfgsyncServingMode, + pub source: CfgsyncServerSource, } -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CfgsyncServerSource { + Bundle { bundle_path: String }, + RegistrationBundle { bundle_path: String }, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum CfgsyncServingMode { - #[default] +enum LegacyServingMode { Bundle, Registration, } +#[derive(Debug, Deserialize)] +struct RawCfgsyncServerConfig { + port: u16, + source: Option, + bundle_path: Option, + serving_mode: Option, +} + #[derive(Debug, Error)] pub enum LoadCfgsyncServerConfigError { #[error("failed to read cfgsync config file {path}: {source}")] @@ -56,11 +68,17 @@ impl CfgsyncServerConfig { source, })?; - serde_yaml::from_str(&config_content).map_err(|source| { - LoadCfgsyncServerConfigError::Parse { - path: config_path, - source, - } + let raw: RawCfgsyncServerConfig = + serde_yaml::from_str(&config_content).map_err(|source| { + LoadCfgsyncServerConfigError::Parse { + path: config_path, + source, + } + })?; + + Self::from_raw(raw).map_err(|source| LoadCfgsyncServerConfigError::Parse { + path: path.display().to_string(), + source, }) } @@ -68,19 +86,43 @@ impl CfgsyncServerConfig { pub fn for_bundle(port: u16, bundle_path: impl Into) -> Self { Self { port, - bundle_path: bundle_path.into(), - serving_mode: CfgsyncServingMode::Bundle, + source: CfgsyncServerSource::Bundle { + bundle_path: bundle_path.into(), + }, } } #[must_use] - pub fn for_registration(port: u16, bundle_path: impl Into) -> Self { + pub fn for_registration_bundle(port: u16, bundle_path: impl Into) -> Self { Self { port, - bundle_path: bundle_path.into(), - serving_mode: CfgsyncServingMode::Registration, + source: CfgsyncServerSource::RegistrationBundle { + bundle_path: bundle_path.into(), + }, } } + + fn from_raw(raw: RawCfgsyncServerConfig) -> Result { + let source = match (raw.source, raw.bundle_path, raw.serving_mode) { + (Some(source), _, _) => source, + (None, Some(bundle_path), Some(LegacyServingMode::Registration)) => { + CfgsyncServerSource::RegistrationBundle { bundle_path } + } + (None, Some(bundle_path), None | Some(LegacyServingMode::Bundle)) => { + CfgsyncServerSource::Bundle { bundle_path } + } + (None, None, _) => { + return Err(serde_yaml::Error::custom( + "cfgsync server config requires source.kind or legacy bundle_path", + )); + } + }; + + Ok(Self { + port: raw.port, + source, + }) + } } fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { @@ -137,7 +179,7 @@ fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::Path /// Loads runtime config and starts cfgsync HTTP server process. pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> { let config = CfgsyncServerConfig::load_from_file(config_path)?; - let bundle_path = resolve_bundle_path(config_path, &config.bundle_path); + let bundle_path = resolve_source_path(config_path, &config.source); let state = build_server_state(&config, &bundle_path)?; serve_cfgsync(config.port, state).await?; @@ -209,12 +251,21 @@ async fn serve_router(port: u16, router: Router) -> Result<(), RunCfgsyncError> fn build_server_state( config: &CfgsyncServerConfig, - bundle_path: &Path, + source_path: &Path, ) -> anyhow::Result { - let repo = match config.serving_mode { - CfgsyncServingMode::Bundle => load_bundle_provider(bundle_path)?, - CfgsyncServingMode::Registration => load_registration_source(bundle_path)?, + let repo = match &config.source { + CfgsyncServerSource::Bundle { .. } => load_bundle_provider(source_path)?, + CfgsyncServerSource::RegistrationBundle { .. } => load_registration_source(source_path)?, }; Ok(CfgsyncServerState::new(repo)) } + +fn resolve_source_path(config_path: &Path, source: &CfgsyncServerSource) -> std::path::PathBuf { + match source { + CfgsyncServerSource::Bundle { bundle_path } + | CfgsyncServerSource::RegistrationBundle { bundle_path } => { + resolve_bundle_path(config_path, bundle_path) + } + } +} diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 9833e57..968cfc2 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -116,15 +116,17 @@ fn build_cfgsync_server_config() -> Value { Value::Number(4400_u64.into()), ); - root.insert( + let mut source = Mapping::new(); + source.insert( + Value::String("kind".to_string()), + Value::String("registration_bundle".to_string()), + ); + source.insert( Value::String("bundle_path".to_string()), Value::String("cfgsync.bundle.yaml".to_string()), ); - root.insert( - Value::String("serving_mode".to_string()), - Value::String("registration".to_string()), - ); + root.insert(Value::String("source".to_string()), Value::Mapping(source)); Value::Mapping(root) } From b90734483b36a90c4df368c29dc1f6bfb5cb16e3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:30:01 +0100 Subject: [PATCH 21/38] Expand cfgsync rustdoc coverage --- cfgsync/adapter/src/artifacts.rs | 25 +++++++++++++++++++++++++ cfgsync/adapter/src/materializer.rs | 18 ++++++++++++++++++ cfgsync/core/src/protocol.rs | 15 +++++++++++++++ cfgsync/runtime/src/server.rs | 24 ++++++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs index 2418ade..1afe2e6 100644 --- a/cfgsync/adapter/src/artifacts.rs +++ b/cfgsync/adapter/src/artifacts.rs @@ -19,21 +19,29 @@ pub struct ArtifactSet { } impl ArtifactSet { + /// Creates one logical artifact group. + /// + /// The same type is used for: + /// - node-local files that belong to one node only + /// - shared files that should be delivered alongside every node #[must_use] pub fn new(files: Vec) -> Self { Self { files } } + /// Returns the files carried by this artifact group. #[must_use] pub fn files(&self) -> &[ArtifactFile] { &self.files } + /// Returns `true` when the group contains no files. #[must_use] pub fn is_empty(&self) -> bool { self.files.is_empty() } + /// Consumes the group and returns its files. #[must_use] pub fn into_files(self) -> Vec { self.files @@ -49,21 +57,25 @@ pub struct ResolvedNodeArtifacts { } impl ResolvedNodeArtifacts { + /// Creates the resolved file set for one node. #[must_use] pub fn new(node: ArtifactSet, shared: ArtifactSet) -> Self { Self { node, shared } } + /// Returns the node-local files. #[must_use] pub fn node(&self) -> &ArtifactSet { &self.node } + /// Returns the shared files delivered alongside every node. #[must_use] pub fn shared(&self) -> &ArtifactSet { &self.shared } + /// Returns the full file list that should be written for this node. #[must_use] pub fn files(&self) -> Vec { let mut files = self.node.files().to_vec(); @@ -79,6 +91,7 @@ pub struct NodeArtifactsCatalog { } impl NodeArtifactsCatalog { + /// Creates a catalog indexed by stable node identifier. #[must_use] pub fn new(nodes: Vec) -> Self { let nodes = nodes @@ -89,21 +102,25 @@ impl NodeArtifactsCatalog { Self { nodes } } + /// Resolves one node's local artifacts by identifier. #[must_use] pub fn resolve(&self, identifier: &str) -> Option<&NodeArtifacts> { self.nodes.get(identifier) } + /// Returns the number of nodes in the catalog. #[must_use] pub fn len(&self) -> usize { self.nodes.len() } + /// Returns `true` when the catalog is empty. #[must_use] pub fn is_empty(&self) -> bool { self.nodes.is_empty() } + /// Consumes the catalog and returns its node entries. #[must_use] pub fn into_nodes(self) -> Vec { self.nodes.into_values().collect() @@ -118,26 +135,34 @@ pub struct MaterializedArtifacts { } impl MaterializedArtifacts { + /// Creates a fully materialized cfgsync result. + /// + /// `nodes` contains node-specific files. + /// `shared` contains files that should accompany every node. #[must_use] pub fn new(nodes: NodeArtifactsCatalog, shared: ArtifactSet) -> Self { Self { nodes, shared } } + /// Creates a materialized result without any shared files. #[must_use] pub fn from_catalog(nodes: NodeArtifactsCatalog) -> Self { Self::new(nodes, ArtifactSet::default()) } + /// Returns the node-specific artifact catalog. #[must_use] pub fn nodes(&self) -> &NodeArtifactsCatalog { &self.nodes } + /// Returns the shared artifact set. #[must_use] pub fn shared(&self) -> &ArtifactSet { &self.shared } + /// Resolves the full file set for one node. #[must_use] pub fn resolve(&self, identifier: &str) -> Option { self.nodes.resolve(identifier).map(|node| { diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index b2d18de..8e00b96 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -10,6 +10,10 @@ pub type DynCfgsyncError = Box; /// Adapter-side materialization contract for a single registered node. pub trait NodeArtifactsMaterializer: Send + Sync { + /// Resolves one node from the current registration set. + /// + /// Returning `Ok(None)` means the node is known but its artifacts are not + /// ready yet. fn materialize( &self, registration: &NodeRegistration, @@ -20,6 +24,13 @@ pub trait NodeArtifactsMaterializer: Send + Sync { /// Adapter contract for materializing a whole registration snapshot into /// per-node cfgsync artifacts. pub trait RegistrationSnapshotMaterializer: Send + Sync { + /// Materializes the current registration set. + /// + /// This is the main registration-driven integration point for cfgsync. + /// Implementations decide: + /// - when the current snapshot is ready to serve + /// - which per-node artifacts should be produced + /// - which shared artifacts should accompany every node fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, @@ -28,6 +39,7 @@ pub trait RegistrationSnapshotMaterializer: Send + Sync { /// Optional hook for persisting or publishing materialized cfgsync artifacts. pub trait MaterializedArtifactsSink: Send + Sync { + /// Persists or publishes a ready materialization result. fn persist(&self, artifacts: &MaterializedArtifacts) -> Result<(), DynCfgsyncError>; } @@ -40,11 +52,13 @@ pub enum MaterializationResult { } impl MaterializationResult { + /// Creates a ready materialization result. #[must_use] pub fn ready(nodes: MaterializedArtifacts) -> Self { Self::Ready(nodes) } + /// Returns the ready artifacts when materialization succeeded. #[must_use] pub fn artifacts(&self) -> Option<&MaterializedArtifacts> { match self { @@ -66,6 +80,8 @@ struct CachedSnapshot { } impl CachedSnapshotMaterializer { + /// Wraps a snapshot materializer with deterministic snapshot-result + /// caching. #[must_use] pub fn new(inner: M) -> Self { Self { @@ -126,6 +142,8 @@ pub struct PersistingSnapshotMaterializer { } impl PersistingSnapshotMaterializer { + /// Wraps a snapshot materializer with one-time persistence for each + /// distinct registration snapshot. #[must_use] pub fn new(inner: M, sink: S) -> Self { Self { diff --git a/cfgsync/core/src/protocol.rs b/cfgsync/core/src/protocol.rs index ca8f205..f794fd1 100644 --- a/cfgsync/core/src/protocol.rs +++ b/cfgsync/core/src/protocol.rs @@ -28,16 +28,19 @@ pub struct RegistrationPayload { } impl RegistrationPayload { + /// Creates an empty adapter-owned payload. #[must_use] pub fn new() -> Self { Self::default() } + /// Returns `true` when no adapter-owned payload was attached. #[must_use] pub fn is_empty(&self) -> bool { self.raw_json.is_none() } + /// Stores one typed adapter payload as opaque JSON. pub fn from_serializable(value: &T) -> Result where T: Serialize, @@ -47,6 +50,7 @@ impl RegistrationPayload { }) } + /// Stores a raw JSON payload after validating that it parses. pub fn from_json_str(raw_json: &str) -> Result { let value: Value = serde_json::from_str(raw_json)?; @@ -55,6 +59,7 @@ impl RegistrationPayload { }) } + /// Deserializes the adapter-owned payload into the requested type. pub fn deserialize(&self) -> Result, serde_json::Error> where T: DeserializeOwned, @@ -65,6 +70,7 @@ impl RegistrationPayload { .transpose() } + /// Returns the validated JSON representation stored in this payload. #[must_use] pub fn raw_json(&self) -> Option<&str> { self.raw_json.as_deref() @@ -111,6 +117,7 @@ pub struct NodeRegistration { } impl NodeRegistration { + /// Creates a registration with the generic node identity fields only. #[must_use] pub fn new(identifier: impl Into, ip: Ipv4Addr) -> Self { Self { @@ -120,6 +127,7 @@ impl NodeRegistration { } } + /// Attaches one typed adapter-owned payload to this registration. pub fn with_metadata(mut self, metadata: &T) -> Result where T: Serialize, @@ -128,6 +136,7 @@ impl NodeRegistration { Ok(self) } + /// Attaches a prebuilt registration payload to this registration. #[must_use] pub fn with_payload(mut self, payload: RegistrationPayload) -> Self { self.metadata = payload; @@ -136,6 +145,7 @@ impl NodeRegistration { } impl NodeArtifactsPayload { + /// Creates a payload from the files that should be written for one node. #[must_use] pub fn from_files(files: Vec) -> Self { Self { @@ -144,11 +154,13 @@ impl NodeArtifactsPayload { } } + /// Returns the files carried by this payload. #[must_use] pub fn files(&self) -> &[NodeArtifactFile] { &self.files } + /// Returns `true` when the payload carries no files. #[must_use] pub fn is_empty(&self) -> bool { self.files.is_empty() @@ -172,6 +184,7 @@ pub struct CfgsyncErrorResponse { } impl CfgsyncErrorResponse { + /// Builds a missing-config error for one identifier. #[must_use] pub fn missing_config(identifier: &str) -> Self { Self { @@ -180,6 +193,7 @@ impl CfgsyncErrorResponse { } } + /// Builds a not-ready error for one identifier. #[must_use] pub fn not_ready(identifier: &str) -> Self { Self { @@ -188,6 +202,7 @@ impl CfgsyncErrorResponse { } } + /// Builds an internal cfgsync error. #[must_use] pub fn internal(message: impl Into) -> Self { Self { diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index cfefd78..0e00628 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -17,9 +17,17 @@ use thiserror::Error; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CfgsyncServerConfig { pub port: u16, + /// Source used by the runtime-managed cfgsync server. pub source: CfgsyncServerSource, } +/// Runtime cfgsync source loaded from config. +/// +/// This type is intentionally runtime-oriented: +/// - `Bundle` serves a static precomputed bundle directly +/// - `RegistrationBundle` serves a precomputed bundle through the registration +/// protocol, which is useful when the consumer wants clients to register +/// before receiving already-materialized artifacts #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CfgsyncServerSource { @@ -189,6 +197,13 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> /// Builds a registration-backed cfgsync router directly from a snapshot /// materializer. +/// +/// This is the main code-driven entrypoint for apps that want cfgsync to own: +/// - node registration +/// - readiness polling +/// - artifact serving +/// +/// while the app owns only snapshot materialization logic. pub fn build_snapshot_cfgsync_router(materializer: M) -> Router where M: RegistrationSnapshotMaterializer + 'static, @@ -199,6 +214,9 @@ where /// Builds a registration-backed cfgsync router with a persistence hook for /// ready materialization results. +/// +/// Use this when the application wants cfgsync to persist or publish shared +/// artifacts after a snapshot becomes ready. pub fn build_persisted_snapshot_cfgsync_router(materializer: M, sink: S) -> Router where M: RegistrationSnapshotMaterializer + 'static, @@ -213,6 +231,9 @@ where /// Runs a registration-backed cfgsync server directly from a snapshot /// materializer. +/// +/// This is the simplest runtime entrypoint when the application already has a +/// materializer value and does not need to compose extra routes. pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> where M: RegistrationSnapshotMaterializer + 'static, @@ -223,6 +244,9 @@ where /// Runs a registration-backed cfgsync server with a persistence hook for ready /// materialization results. +/// +/// This is the direct serving counterpart to +/// [`build_persisted_snapshot_cfgsync_router`]. pub async fn serve_persisted_snapshot_cfgsync( port: u16, materializer: M, From fd154a948789dbf1968236fb53e1ff314befbe3a Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:35:22 +0100 Subject: [PATCH 22/38] Expand cfgsync rustdoc coverage further --- cfgsync/adapter/src/registrations.rs | 5 +++++ cfgsync/core/src/bundle.rs | 11 +++++++++-- cfgsync/core/src/client.rs | 8 ++++++++ cfgsync/core/src/protocol.rs | 12 ++++++++++++ cfgsync/core/src/render.rs | 7 +++++++ cfgsync/core/src/server.rs | 4 ++++ cfgsync/core/src/source.rs | 6 ++++++ cfgsync/runtime/src/client.rs | 2 ++ cfgsync/runtime/src/server.rs | 5 +++++ 9 files changed, 58 insertions(+), 2 deletions(-) diff --git a/cfgsync/adapter/src/registrations.rs b/cfgsync/adapter/src/registrations.rs index 47e365f..4970a8b 100644 --- a/cfgsync/adapter/src/registrations.rs +++ b/cfgsync/adapter/src/registrations.rs @@ -8,6 +8,7 @@ pub struct RegistrationSnapshot { } impl RegistrationSnapshot { + /// Creates a stable registration snapshot sorted by node identifier. #[must_use] pub fn new(mut registrations: Vec) -> Self { registrations.sort_by(|left, right| left.identifier.cmp(&right.identifier)); @@ -15,21 +16,25 @@ impl RegistrationSnapshot { Self { registrations } } + /// Returns the number of registrations in the snapshot. #[must_use] pub fn len(&self) -> usize { self.registrations.len() } + /// Returns `true` when the snapshot contains no registrations. #[must_use] pub fn is_empty(&self) -> bool { self.registrations.is_empty() } + /// Iterates registrations in deterministic identifier order. #[must_use] pub fn iter(&self) -> impl Iterator { self.registrations.iter() } + /// Looks up a registration by its stable node identifier. #[must_use] pub fn get(&self, identifier: &str) -> Option<&NodeRegistration> { self.registrations diff --git a/cfgsync/core/src/bundle.rs b/cfgsync/core/src/bundle.rs index 31f6723..3fc5675 100644 --- a/cfgsync/core/src/bundle.rs +++ b/cfgsync/core/src/bundle.rs @@ -2,15 +2,21 @@ use serde::{Deserialize, Serialize}; use crate::NodeArtifactFile; -/// Top-level cfgsync bundle containing per-node file payloads. +/// Static cfgsync artifact bundle. +/// +/// This is the bundle-oriented source format used when all artifacts are known +/// ahead of time and no registration-time materialization is required. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeArtifactsBundle { + /// Per-node artifact entries keyed by identifier. pub nodes: Vec, + /// Files that should be served alongside every node-specific entry. #[serde(default)] pub shared_files: Vec, } impl NodeArtifactsBundle { + /// Creates a bundle with node-specific entries only. #[must_use] pub fn new(nodes: Vec) -> Self { Self { @@ -19,6 +25,7 @@ impl NodeArtifactsBundle { } } + /// Attaches files that should be served alongside every node entry. #[must_use] pub fn with_shared_files(mut self, shared_files: Vec) -> Self { self.shared_files = shared_files; @@ -26,7 +33,7 @@ impl NodeArtifactsBundle { } } -/// Artifact set for a single node resolved by identifier. +/// One node entry inside a static cfgsync bundle. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeArtifactsBundleEntry { /// Stable node identifier used by cfgsync lookup. diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index 4032c35..d682936 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -18,10 +18,14 @@ pub enum ClientError { Decode(serde_json::Error), } +/// Result of probing cfgsync for a node's current artifact availability. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigFetchStatus { + /// The node payload is ready and can be fetched successfully. Ready, + /// The node has registered but artifacts are not ready yet. NotReady, + /// The server does not know how to materialize artifacts for this node. Missing, } @@ -33,6 +37,7 @@ pub struct CfgsyncClient { } impl CfgsyncClient { + /// Creates a cfgsync client pointed at the given server base URL. #[must_use] pub fn new(base_url: impl Into) -> Self { let mut base_url = base_url.into(); @@ -45,6 +50,7 @@ impl CfgsyncClient { } } + /// Returns the normalized cfgsync server base URL used for requests. #[must_use] pub fn base_url(&self) -> &str { &self.base_url @@ -63,6 +69,8 @@ impl CfgsyncClient { self.post_json("/node", payload).await } + /// Probes whether artifacts for a node are ready, missing, or still + /// pending. pub async fn fetch_node_config_status( &self, payload: &NodeRegistration, diff --git a/cfgsync/core/src/protocol.rs b/cfgsync/core/src/protocol.rs index f794fd1..1bba7d7 100644 --- a/cfgsync/core/src/protocol.rs +++ b/cfgsync/core/src/protocol.rs @@ -110,8 +110,11 @@ impl<'de> Deserialize<'de> for RegistrationPayload { /// Node metadata recorded before config materialization. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct NodeRegistration { + /// Stable node identifier used for registration and artifact lookup. pub identifier: String, + /// IPv4 address advertised as part of registration. pub ip: Ipv4Addr, + /// Adapter-owned payload interpreted only by the app materializer. #[serde(default, skip_serializing_if = "RegistrationPayload::is_empty")] pub metadata: RegistrationPayload, } @@ -170,8 +173,11 @@ impl NodeArtifactsPayload { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CfgsyncErrorCode { + /// No artifact payload is available for the requested node. MissingConfig, + /// The node is registered but artifacts are not ready yet. NotReady, + /// An unexpected server-side failure occurred. Internal, } @@ -179,7 +185,9 @@ pub enum CfgsyncErrorCode { #[derive(Debug, Clone, Serialize, Deserialize, Error)] #[error("{code:?}: {message}")] pub struct CfgsyncErrorResponse { + /// Machine-readable failure category. pub code: CfgsyncErrorCode, + /// Human-readable error details. pub message: String, } @@ -214,13 +222,17 @@ impl CfgsyncErrorResponse { /// Resolution outcome for a requested node identifier. pub enum ConfigResolveResponse { + /// Artifacts are ready for the requested node. Config(NodeArtifactsPayload), + /// Artifacts could not be resolved for the requested node. Error(CfgsyncErrorResponse), } /// Outcome for a node registration request. pub enum RegisterNodeResponse { + /// Registration was accepted. Registered, + /// Registration failed. Error(CfgsyncErrorResponse), } diff --git a/cfgsync/core/src/render.rs b/cfgsync/core/src/render.rs index 2031986..b963a99 100644 --- a/cfgsync/core/src/render.rs +++ b/cfgsync/core/src/render.rs @@ -16,7 +16,9 @@ pub struct RenderedCfgsync { /// Output paths used when materializing rendered cfgsync files. #[derive(Debug, Clone, Copy)] pub struct CfgsyncOutputPaths<'a> { + /// Output path for the rendered server config YAML. pub config_path: &'a Path, + /// Output path for the rendered static bundle YAML. pub bundle_path: &'a Path, } @@ -55,10 +57,15 @@ pub fn write_rendered_cfgsync( /// Optional overrides applied to a cfgsync template document. #[derive(Debug, Clone, Default)] pub struct CfgsyncConfigOverrides { + /// Override for the HTTP listen port. pub port: Option, + /// Override for the expected initial host count. pub n_hosts: Option, + /// Minimum timeout to enforce on the rendered template. pub timeout_floor_secs: Option, + /// Override for the bundle path written into cfgsync config. pub bundle_path: Option, + /// Optional OTLP metrics endpoint injected into tracing settings. pub metrics_otlp_ingest_url: Option, } diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 3a82a17..925e997 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -14,6 +14,7 @@ pub struct CfgsyncServerState { } impl CfgsyncServerState { + /// Wraps a node config source for use by cfgsync HTTP handlers. #[must_use] pub fn new(repo: Arc) -> Self { Self { repo } @@ -83,6 +84,8 @@ fn error_status(code: &CfgsyncErrorCode) -> StatusCode { } } +/// Builds the primary cfgsync router with registration and node artifact +/// routes. pub fn build_cfgsync_router(state: CfgsyncServerState) -> Router { Router::new() .route("/register", post(register_node)) @@ -91,6 +94,7 @@ pub fn build_cfgsync_router(state: CfgsyncServerState) -> Router { } #[doc(hidden)] +/// Builds the legacy cfgsync router that still serves `/init-with-node`. pub fn build_legacy_cfgsync_router(state: CfgsyncServerState) -> Router { Router::new() .route("/register", post(register_node)) diff --git a/cfgsync/core/src/source.rs b/cfgsync/core/src/source.rs index 9d00d9e..2cdf513 100644 --- a/cfgsync/core/src/source.rs +++ b/cfgsync/core/src/source.rs @@ -9,8 +9,10 @@ use crate::{ /// Source of cfgsync node payloads. pub trait NodeConfigSource: Send + Sync { + /// Records a node registration before config resolution. fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse; + /// Resolves the current artifact payload for a previously registered node. fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse; } @@ -20,11 +22,13 @@ pub struct StaticConfigSource { } impl StaticConfigSource { + /// Builds an in-memory source from fully formed payloads. #[must_use] pub fn from_payloads(configs: HashMap) -> Arc { Arc::new(Self { configs }) } + /// Builds an in-memory source from a static bundle document. #[must_use] pub fn from_bundle(bundle: NodeArtifactsBundle) -> Arc { Self::from_payloads(bundle_to_payload_map(bundle)) @@ -73,6 +77,7 @@ pub enum BundleLoadError { }, } +/// Converts a static bundle into the node payload map used by static sources. #[must_use] pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap { let shared_files = bundle.shared_files; @@ -91,6 +96,7 @@ pub fn bundle_to_payload_map(bundle: NodeArtifactsBundle) -> HashMap Result { let path_string = path.display().to_string(); let raw = fs::read_to_string(path).map_err(|source| BundleLoadError::ReadBundle { diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index b693ad9..510da04 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -24,11 +24,13 @@ pub struct ArtifactOutputMap { } impl ArtifactOutputMap { + /// Creates an empty artifact output map. #[must_use] pub fn new() -> Self { Self::default() } + /// Routes one artifact path from the payload to a local output path. #[must_use] pub fn route( mut self, diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 0e00628..9f788d4 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -16,6 +16,7 @@ use thiserror::Error; /// Runtime cfgsync server config loaded from YAML. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CfgsyncServerConfig { + /// HTTP port to bind the cfgsync server on. pub port: u16, /// Source used by the runtime-managed cfgsync server. pub source: CfgsyncServerSource, @@ -31,7 +32,9 @@ pub struct CfgsyncServerConfig { #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CfgsyncServerSource { + /// Serve a static precomputed artifact bundle directly. Bundle { bundle_path: String }, + /// Require node registration before serving artifacts from a static bundle. RegistrationBundle { bundle_path: String }, } @@ -100,6 +103,8 @@ impl CfgsyncServerConfig { } } + /// Builds a config that serves a static bundle behind the registration + /// flow. #[must_use] pub fn for_registration_bundle(port: u16, bundle_path: impl Into) -> Self { Self { From fb0129020c147b29d511cb1fed4fdf347bb673e8 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:44:20 +0100 Subject: [PATCH 23/38] Simplify cfgsync adapter surface --- Cargo.lock | 1 + cfgsync/README.md | 41 ++- cfgsync/adapter/src/artifacts.rs | 189 +++--------- cfgsync/adapter/src/deployment.rs | 37 +-- cfgsync/adapter/src/lib.rs | 13 +- cfgsync/adapter/src/materializer.rs | 105 ++++--- cfgsync/adapter/src/sources.rs | 334 +++++----------------- cfgsync/runtime/Cargo.toml | 21 +- cfgsync/runtime/src/server.rs | 22 +- logos/runtime/ext/src/cfgsync/mod.rs | 49 ++-- testing-framework/core/src/cfgsync/mod.rs | 2 +- 11 files changed, 248 insertions(+), 566 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b740bc..9655dc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,7 @@ dependencies = [ "anyhow", "axum", "cfgsync-adapter", + "cfgsync-artifacts", "cfgsync-core", "clap", "serde", diff --git a/cfgsync/README.md b/cfgsync/README.md index 28adeee..7431676 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -56,25 +56,19 @@ Typical flow: Adapter-facing materialization layer. Primary types: -- `NodeArtifacts` -- `NodeArtifactsCatalog` +- `MaterializedArtifacts` - `RegistrationSnapshot` -- `NodeArtifactsMaterializer` - `RegistrationSnapshotMaterializer` - `CachedSnapshotMaterializer` -- `MaterializingConfigSource` -- `SnapshotConfigSource` +- `RegistrationConfigSource` - `DeploymentAdapter` This crate is where app-specific bootstrap logic plugs in. -Two useful patterns exist: -- single-node materialization - - `NodeArtifactsMaterializer` -- whole-snapshot materialization - - `RegistrationSnapshotMaterializer` +The main pattern is snapshot materialization: +- `RegistrationSnapshotMaterializer` -Use snapshot materialization when readiness depends on the full registered set. +Use it when readiness depends on the full registered set. ### `cfgsync-runtime` Small runtime helpers and binaries. @@ -111,7 +105,7 @@ Config is produced from node registrations. Use: - `RegistrationSnapshotMaterializer` - `CachedSnapshotMaterializer` -- `SnapshotConfigSource` +- `RegistrationConfigSource` - `serve_snapshot_cfgsync(...)` This is the right model when config readiness depends on the current registered set. @@ -151,10 +145,10 @@ let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().unwrap()) ```rust use cfgsync_adapter::{ - DynCfgsyncError, NodeArtifacts, NodeArtifactsCatalog, RegistrationSnapshot, + DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot, RegistrationSnapshotMaterializer, }; -use cfgsync_artifacts::ArtifactFile; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; struct MyMaterializer; @@ -162,23 +156,24 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { fn materialize_snapshot( &self, registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { + ) -> Result { if registrations.len() < 2 { - return Ok(None); + return Ok(MaterializationResult::NotReady); } let nodes = registrations .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new( + .map(|registration| ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new( "/config.yaml", format!("id: {}\n", registration.identifier), - )], - }) - .collect(); + )]), + )); - Ok(Some(NodeArtifactsCatalog::new(nodes))) + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_nodes(nodes), + )) } } ``` diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs index 1afe2e6..024dad6 100644 --- a/cfgsync/adapter/src/artifacts.rs +++ b/cfgsync/adapter/src/artifacts.rs @@ -1,159 +1,38 @@ use std::collections::HashMap; -use cfgsync_artifacts::ArtifactFile; -use serde::{Deserialize, Serialize}; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; -/// Per-node artifact payload served by cfgsync for one registered node. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeArtifacts { - /// Stable node identifier resolved by the adapter. - pub identifier: String, - /// Files served to the node after cfgsync registration. - pub files: Vec, -} - -/// Materialized artifact files for a single registered node. -#[derive(Debug, Clone, Default)] -pub struct ArtifactSet { - files: Vec, -} - -impl ArtifactSet { - /// Creates one logical artifact group. - /// - /// The same type is used for: - /// - node-local files that belong to one node only - /// - shared files that should be delivered alongside every node - #[must_use] - pub fn new(files: Vec) -> Self { - Self { files } - } - - /// Returns the files carried by this artifact group. - #[must_use] - pub fn files(&self) -> &[ArtifactFile] { - &self.files - } - - /// Returns `true` when the group contains no files. - #[must_use] - pub fn is_empty(&self) -> bool { - self.files.is_empty() - } - - /// Consumes the group and returns its files. - #[must_use] - pub fn into_files(self) -> Vec { - self.files - } -} - -/// Resolved artifact payload for one node, including any shared files that -/// should be delivered alongside the node-local files. -#[derive(Debug, Clone, Default)] -pub struct ResolvedNodeArtifacts { - node: ArtifactSet, - shared: ArtifactSet, -} - -impl ResolvedNodeArtifacts { - /// Creates the resolved file set for one node. - #[must_use] - pub fn new(node: ArtifactSet, shared: ArtifactSet) -> Self { - Self { node, shared } - } - - /// Returns the node-local files. - #[must_use] - pub fn node(&self) -> &ArtifactSet { - &self.node - } - - /// Returns the shared files delivered alongside every node. - #[must_use] - pub fn shared(&self) -> &ArtifactSet { - &self.shared - } - - /// Returns the full file list that should be written for this node. - #[must_use] - pub fn files(&self) -> Vec { - let mut files = self.node.files().to_vec(); - files.extend_from_slice(self.shared.files()); - files - } -} - -/// Artifact payloads indexed by stable node identifier. -#[derive(Debug, Clone, Default)] -pub struct NodeArtifactsCatalog { - nodes: HashMap, -} - -impl NodeArtifactsCatalog { - /// Creates a catalog indexed by stable node identifier. - #[must_use] - pub fn new(nodes: Vec) -> Self { - let nodes = nodes - .into_iter() - .map(|node| (node.identifier.clone(), node)) - .collect(); - - Self { nodes } - } - - /// Resolves one node's local artifacts by identifier. - #[must_use] - pub fn resolve(&self, identifier: &str) -> Option<&NodeArtifacts> { - self.nodes.get(identifier) - } - - /// Returns the number of nodes in the catalog. - #[must_use] - pub fn len(&self) -> usize { - self.nodes.len() - } - - /// Returns `true` when the catalog is empty. - #[must_use] - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - /// Consumes the catalog and returns its node entries. - #[must_use] - pub fn into_nodes(self) -> Vec { - self.nodes.into_values().collect() - } -} - -/// Materialized cfgsync output for a whole registration set. +/// Fully materialized cfgsync artifacts for a registration set. +/// +/// `nodes` holds the node-local files keyed by stable node identifier. +/// `shared` holds files that should be delivered alongside every node. #[derive(Debug, Clone, Default)] pub struct MaterializedArtifacts { - nodes: NodeArtifactsCatalog, + nodes: HashMap, shared: ArtifactSet, } impl MaterializedArtifacts { - /// Creates a fully materialized cfgsync result. - /// - /// `nodes` contains node-specific files. - /// `shared` contains files that should accompany every node. + /// Creates materialized artifacts from node-local artifact sets. #[must_use] - pub fn new(nodes: NodeArtifactsCatalog, shared: ArtifactSet) -> Self { - Self { nodes, shared } + pub fn from_nodes(nodes: impl IntoIterator) -> Self { + Self { + nodes: nodes.into_iter().collect(), + shared: ArtifactSet::default(), + } } - /// Creates a materialized result without any shared files. + /// Attaches shared files delivered alongside every node. #[must_use] - pub fn from_catalog(nodes: NodeArtifactsCatalog) -> Self { - Self::new(nodes, ArtifactSet::default()) + pub fn with_shared(mut self, shared: ArtifactSet) -> Self { + self.shared = shared; + self } - /// Returns the node-specific artifact catalog. + /// Returns the node-local artifact set for one identifier. #[must_use] - pub fn nodes(&self) -> &NodeArtifactsCatalog { - &self.nodes + pub fn node(&self, identifier: &str) -> Option<&ArtifactSet> { + self.nodes.get(identifier) } /// Returns the shared artifact set. @@ -162,11 +41,31 @@ impl MaterializedArtifacts { &self.shared } - /// Resolves the full file set for one node. + /// Returns the number of node-local artifact sets. #[must_use] - pub fn resolve(&self, identifier: &str) -> Option { - self.nodes.resolve(identifier).map(|node| { - ResolvedNodeArtifacts::new(ArtifactSet::new(node.files.clone()), self.shared.clone()) - }) + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Returns `true` when no node-local artifact sets are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Resolves the full file set that should be written for one node. + #[must_use] + pub fn resolve(&self, identifier: &str) -> Option { + let node = self.node(identifier)?; + let mut files: Vec = node.files.clone(); + files.extend(self.shared.files.iter().cloned()); + Some(ArtifactSet::new(files)) + } + + /// Iterates node-local artifact sets by stable identifier. + pub fn iter(&self) -> impl Iterator { + self.nodes + .iter() + .map(|(identifier, artifacts)| (identifier.as_str(), artifacts)) } } diff --git a/cfgsync/adapter/src/deployment.rs b/cfgsync/adapter/src/deployment.rs index c2e37bc..ba8c272 100644 --- a/cfgsync/adapter/src/deployment.rs +++ b/cfgsync/adapter/src/deployment.rs @@ -1,9 +1,9 @@ -use std::error::Error; +use std::{collections::HashMap, error::Error}; -use cfgsync_artifacts::ArtifactFile; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; use thiserror::Error; -use crate::{NodeArtifacts, NodeArtifactsCatalog}; +use crate::MaterializedArtifacts; /// Adapter contract for converting an application deployment model into /// node-specific serialized config payloads. @@ -53,32 +53,25 @@ where } } -/// Builds cfgsync node configs for a deployment by: +/// Builds materialized cfgsync artifacts for a deployment by: /// 1) validating hostname count, /// 2) building each node config, /// 3) rewriting host references, /// 4) serializing each node payload. -pub fn build_cfgsync_node_configs( +pub fn build_materialized_artifacts( deployment: &E::Deployment, hostnames: &[String], -) -> Result, BuildCfgsyncNodesError> { - Ok(build_node_artifact_catalog::(deployment, hostnames)?.into_nodes()) -} - -/// Builds cfgsync node configs and indexes them by stable identifier. -pub fn build_node_artifact_catalog( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result { +) -> Result { let nodes = E::nodes(deployment); ensure_hostname_count(nodes.len(), hostnames.len())?; - let mut output = Vec::with_capacity(nodes.len()); + let mut output = HashMap::with_capacity(nodes.len()); for (index, node) in nodes.iter().enumerate() { - output.push(build_node_entry::(deployment, node, index, hostnames)?); + let (identifier, artifacts) = build_node_entry::(deployment, node, index, hostnames)?; + output.insert(identifier, artifacts); } - Ok(NodeArtifactsCatalog::new(output)) + Ok(MaterializedArtifacts::from_nodes(output)) } fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { @@ -94,14 +87,14 @@ fn build_node_entry( node: &E::Node, index: usize, hostnames: &[String], -) -> Result { +) -> Result<(String, ArtifactSet), BuildCfgsyncNodesError> { let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; - Ok(NodeArtifacts { - identifier: E::node_identifier(index, node), - files: vec![ArtifactFile::new("/config.yaml", &config_yaml)], - }) + Ok(( + E::node_identifier(index, node), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", &config_yaml)]), + )) } fn build_rewritten_node_config( diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index 0ddf349..d202854 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -4,16 +4,11 @@ mod materializer; mod registrations; mod sources; -pub use artifacts::{ - ArtifactSet, MaterializedArtifacts, NodeArtifacts, NodeArtifactsCatalog, ResolvedNodeArtifacts, -}; -pub use deployment::{ - BuildCfgsyncNodesError, DeploymentAdapter, build_cfgsync_node_configs, - build_node_artifact_catalog, -}; +pub use artifacts::MaterializedArtifacts; +pub use deployment::{BuildCfgsyncNodesError, DeploymentAdapter, build_materialized_artifacts}; pub use materializer::{ CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifactsSink, - NodeArtifactsMaterializer, PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, + PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, }; pub use registrations::RegistrationSnapshot; -pub use sources::{MaterializingConfigSource, SnapshotConfigSource}; +pub use sources::RegistrationConfigSource; diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index 8e00b96..8c7b991 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -1,32 +1,17 @@ use std::{error::Error, sync::Mutex}; -use cfgsync_core::NodeRegistration; use serde_json::to_string; -use crate::{MaterializedArtifacts, RegistrationSnapshot, ResolvedNodeArtifacts}; +use crate::{MaterializedArtifacts, RegistrationSnapshot}; /// Type-erased cfgsync adapter error used to preserve source context. pub type DynCfgsyncError = Box; -/// Adapter-side materialization contract for a single registered node. -pub trait NodeArtifactsMaterializer: Send + Sync { - /// Resolves one node from the current registration set. - /// - /// Returning `Ok(None)` means the node is known but its artifacts are not - /// ready yet. - fn materialize( - &self, - registration: &NodeRegistration, - registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError>; -} - /// Adapter contract for materializing a whole registration snapshot into -/// per-node cfgsync artifacts. +/// cfgsync artifacts. pub trait RegistrationSnapshotMaterializer: Send + Sync { /// Materializes the current registration set. /// - /// This is the main registration-driven integration point for cfgsync. /// Implementations decide: /// - when the current snapshot is ready to serve /// - which per-node artifacts should be produced @@ -54,8 +39,8 @@ pub enum MaterializationResult { impl MaterializationResult { /// Creates a ready materialization result. #[must_use] - pub fn ready(nodes: MaterializedArtifacts) -> Self { - Self::Ready(nodes) + pub fn ready(artifacts: MaterializedArtifacts) -> Self { + Self::Ready(artifacts) } /// Returns the ready artifacts when materialization succeeded. @@ -200,14 +185,14 @@ mod tests { atomic::{AtomicUsize, Ordering}, }; - use cfgsync_artifacts::ArtifactFile; + use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; use super::{ CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifacts, MaterializedArtifactsSink, PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, }; - use crate::{ArtifactSet, NodeArtifacts, NodeArtifactsCatalog, RegistrationSnapshot}; + use crate::RegistrationSnapshot; struct CountingMaterializer; @@ -220,18 +205,18 @@ mod tests { return Ok(MaterializationResult::NotReady); } - let nodes = registrations - .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new("/config.yaml", "ready: true")], - }) - .collect(); + let nodes = registrations.iter().map(|registration| { + ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "ready: true")]), + ) + }); - Ok(MaterializationResult::ready(MaterializedArtifacts::new( - NodeArtifactsCatalog::new(nodes), - ArtifactSet::new(vec![ArtifactFile::new("/shared.yaml", "cluster: ready")]), - ))) + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(vec![ + ArtifactFile::new("/shared.yaml", "cluster: ready"), + ])), + )) } } @@ -247,30 +232,44 @@ mod tests { } #[test] - fn persisting_snapshot_materializer_writes_ready_snapshots_once() { - let writes = Arc::new(AtomicUsize::new(0)); - let materializer = CachedSnapshotMaterializer::new(PersistingSnapshotMaterializer::new( - CountingMaterializer, - CountingSink { - writes: Arc::clone(&writes), - }, - )); - - let empty = RegistrationSnapshot::default(); - let ready = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( - "node-0", + fn cached_snapshot_materializer_reuses_previous_result() { + let materializer = CachedSnapshotMaterializer::new(CountingMaterializer); + let snapshot = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( + "node-1", "127.0.0.1".parse().expect("parse ip"), )]); - let _ = materializer - .materialize_snapshot(&empty) - .expect("not-ready snapshot"); - let _ = materializer - .materialize_snapshot(&ready) - .expect("ready snapshot"); - let _ = materializer - .materialize_snapshot(&ready) - .expect("cached ready snapshot"); + let first = materializer + .materialize_snapshot(&snapshot) + .expect("first materialization"); + let second = materializer + .materialize_snapshot(&snapshot) + .expect("second materialization"); + + assert!(matches!(first, MaterializationResult::Ready(_))); + assert!(matches!(second, MaterializationResult::Ready(_))); + } + + #[test] + fn persisting_snapshot_materializer_writes_ready_snapshots_once() { + let writes = Arc::new(AtomicUsize::new(0)); + let materializer = PersistingSnapshotMaterializer::new( + CountingMaterializer, + CountingSink { + writes: writes.clone(), + }, + ); + let snapshot = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( + "node-1", + "127.0.0.1".parse().expect("parse ip"), + )]); + + materializer + .materialize_snapshot(&snapshot) + .expect("first materialization"); + materializer + .materialize_snapshot(&snapshot) + .expect("second materialization"); assert_eq!(writes.load(Ordering::SeqCst), 1); } diff --git a/cfgsync/adapter/src/sources.rs b/cfgsync/adapter/src/sources.rs index 4e4c1a4..b3e651d 100644 --- a/cfgsync/adapter/src/sources.rs +++ b/cfgsync/adapter/src/sources.rs @@ -6,47 +6,10 @@ use cfgsync_core::{ }; use crate::{ - ArtifactSet, DynCfgsyncError, MaterializationResult, MaterializedArtifacts, - NodeArtifactsCatalog, NodeArtifactsMaterializer, RegistrationSnapshot, - RegistrationSnapshotMaterializer, ResolvedNodeArtifacts, + DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot, + RegistrationSnapshotMaterializer, }; -impl NodeArtifactsMaterializer for NodeArtifactsCatalog { - fn materialize( - &self, - registration: &NodeRegistration, - _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { - Ok(self.resolve(®istration.identifier).map(|artifacts| { - ResolvedNodeArtifacts::new( - build_artifact_set_from_catalog_entry(artifacts), - ArtifactSet::default(), - ) - })) - } -} - -impl RegistrationSnapshotMaterializer for NodeArtifactsCatalog { - fn materialize_snapshot( - &self, - _registrations: &RegistrationSnapshot, - ) -> Result { - Ok(MaterializationResult::ready( - MaterializedArtifacts::from_catalog(self.clone()), - )) - } -} - -impl NodeArtifactsMaterializer for MaterializedArtifacts { - fn materialize( - &self, - registration: &NodeRegistration, - _registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { - Ok(self.resolve(®istration.identifier)) - } -} - impl RegistrationSnapshotMaterializer for MaterializedArtifacts { fn materialize_snapshot( &self, @@ -56,87 +19,13 @@ impl RegistrationSnapshotMaterializer for MaterializedArtifacts { } } -/// Registration-aware source backed by an adapter materializer. -pub struct MaterializingConfigSource { - materializer: M, - registrations: Mutex>, -} - -impl MaterializingConfigSource { - #[must_use] - pub fn new(materializer: M) -> Self { - Self { - materializer, - registrations: Mutex::new(HashMap::new()), - } - } - - fn registration_for(&self, identifier: &str) -> Option { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - registrations.get(identifier).cloned() - } - - fn registration_snapshot(&self) -> RegistrationSnapshot { - let registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - - RegistrationSnapshot::new(registrations.values().cloned().collect()) - } -} - -impl NodeConfigSource for MaterializingConfigSource -where - M: NodeArtifactsMaterializer, -{ - fn register(&self, registration: NodeRegistration) -> RegisterNodeResponse { - let mut registrations = self - .registrations - .lock() - .expect("cfgsync registration store should not be poisoned"); - registrations.insert(registration.identifier.clone(), registration); - - RegisterNodeResponse::Registered - } - - fn resolve(&self, registration: &NodeRegistration) -> ConfigResolveResponse { - let registration = match self.registration_for(®istration.identifier) { - Some(registration) => registration, - None => { - return ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )); - } - }; - let registrations = self.registration_snapshot(); - - match self.materializer.materialize(®istration, ®istrations) { - Ok(Some(artifacts)) => { - ConfigResolveResponse::Config(NodeArtifactsPayload::from_files(artifacts.files())) - } - Ok(None) => ConfigResolveResponse::Error(CfgsyncErrorResponse::not_ready( - ®istration.identifier, - )), - Err(error) => ConfigResolveResponse::Error(CfgsyncErrorResponse::internal(format!( - "failed to materialize config for host {}: {error}", - registration.identifier - ))), - } - } -} - /// Registration-aware source backed by a snapshot materializer. -pub struct SnapshotConfigSource { +pub struct RegistrationConfigSource { materializer: M, registrations: Mutex>, } -impl SnapshotConfigSource { +impl RegistrationConfigSource { #[must_use] pub fn new(materializer: M) -> Self { Self { @@ -164,7 +53,7 @@ impl SnapshotConfigSource { } } -impl NodeConfigSource for SnapshotConfigSource +impl NodeConfigSource for RegistrationConfigSource where M: RegistrationSnapshotMaterializer, { @@ -205,7 +94,7 @@ where match materialized.resolve(®istration.identifier) { Some(config) => { - ConfigResolveResponse::Config(NodeArtifactsPayload::from_files(config.files())) + ConfigResolveResponse::Config(NodeArtifactsPayload::from_files(config.files)) } None => ConfigResolveResponse::Error(CfgsyncErrorResponse::missing_config( ®istration.identifier, @@ -214,133 +103,56 @@ where } } -fn build_artifact_set_from_catalog_entry(config: &crate::NodeArtifacts) -> ArtifactSet { - ArtifactSet::new(config.files.clone()) -} - #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use cfgsync_artifacts::ArtifactFile; + use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; use cfgsync_core::{ CfgsyncErrorCode, ConfigResolveResponse, NodeConfigSource, NodeRegistration, }; - use super::{MaterializingConfigSource, SnapshotConfigSource}; + use super::RegistrationConfigSource; use crate::{ - ArtifactSet, CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, - MaterializedArtifacts, NodeArtifacts, NodeArtifactsCatalog, NodeArtifactsMaterializer, - RegistrationSnapshot, RegistrationSnapshotMaterializer, ResolvedNodeArtifacts, + CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifacts, + RegistrationSnapshot, RegistrationSnapshotMaterializer, }; #[test] - fn catalog_resolves_identifier() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); + fn registration_source_resolves_identifier() { + let artifacts = MaterializedArtifacts::from_nodes([( + "node-1".to_owned(), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "a: 1")]), + )]); + let source = RegistrationConfigSource::new(artifacts); - let node = catalog.resolve("node-1").expect("resolve node config"); - - assert_eq!(node.files[0].content, "key: value"); - } - - #[test] - fn materializing_source_resolves_registered_node() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); - let source = MaterializingConfigSource::new(catalog); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); - let _ = source.register(registration.clone()); match source.resolve(®istration) { - ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.files()[0].path, "/config.yaml") - } + ConfigResolveResponse::Config(payload) => assert_eq!(payload.files.len(), 1), ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } #[test] - fn materializing_source_reports_not_ready_before_registration() { - let catalog = NodeArtifactsCatalog::new(vec![NodeArtifacts { - identifier: "node-1".to_owned(), - files: vec![ArtifactFile::new("/config.yaml", "key: value")], - }]); - let source = MaterializingConfigSource::new(catalog); + fn registration_source_reports_not_ready_before_registration() { + let artifacts = MaterializedArtifacts::from_nodes([( + "node-1".to_owned(), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "a: 1")]), + )]); + let source = RegistrationConfigSource::new(artifacts); + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); match source.resolve(®istration) { - ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + ConfigResolveResponse::Config(_) => panic!("expected not-ready"), ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)); } } } - struct ThresholdMaterializer { - calls: AtomicUsize, - } - - impl NodeArtifactsMaterializer for ThresholdMaterializer { - fn materialize( - &self, - registration: &NodeRegistration, - registrations: &RegistrationSnapshot, - ) -> Result, DynCfgsyncError> { - self.calls.fetch_add(1, Ordering::SeqCst); - - if registrations.len() < 2 { - return Ok(None); - } - - let peer_count = registrations.iter().count(); - let files = vec![ - ArtifactFile::new("/config.yaml", format!("id: {}", registration.identifier)), - ArtifactFile::new("/peers.txt", peer_count.to_string()), - ]; - - Ok(Some(ResolvedNodeArtifacts::new( - crate::ArtifactSet::new(files), - ArtifactSet::default(), - ))) - } - } - - #[test] - fn materializing_source_passes_registration_snapshot() { - let source = MaterializingConfigSource::new(ThresholdMaterializer { - calls: AtomicUsize::new(0), - }); - let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); - let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); - - let _ = source.register(node_a.clone()); - - match source.resolve(&node_a) { - ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), - ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) - } - } - - let _ = source.register(node_b); - - match source.resolve(&node_a) { - ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.files()[0].content, "id: node-a"); - assert_eq!(payload.files()[1].content, "2"); - } - ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), - } - - assert_eq!(source.materializer.calls.load(Ordering::SeqCst), 2); - } - struct ThresholdSnapshotMaterializer; impl RegistrationSnapshotMaterializer for ThresholdSnapshotMaterializer { @@ -352,56 +164,52 @@ mod tests { return Ok(MaterializationResult::NotReady); } - let nodes = registrations - .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new( + let nodes = registrations.iter().map(|registration| { + ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new( "/config.yaml", - format!("peer_count: {}", registrations.len()), - )], - }) - .collect(); - let shared = ArtifactSet::new(vec![ArtifactFile::new( - "/shared.txt", - format!("shared_count: {}", registrations.len()), - )]); + format!("id: {}", registration.identifier), + )]), + ) + }); - Ok(MaterializationResult::ready(MaterializedArtifacts::new( - NodeArtifactsCatalog::new(nodes), - shared, - ))) + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(vec![ + ArtifactFile::new("/shared.yaml", "cluster: ready"), + ])), + )) } } #[test] - fn snapshot_source_materializes_from_registration_snapshot() { - let source = SnapshotConfigSource::new(ThresholdSnapshotMaterializer); - let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); - let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + fn registration_source_materializes_from_registration_snapshot() { + let source = RegistrationConfigSource::new(ThresholdSnapshotMaterializer); + let node_1 = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + let node_2 = NodeRegistration::new("node-2", "127.0.0.2".parse().expect("parse ip")); - let _ = source.register(node_a.clone()); - - match source.resolve(&node_a) { - ConfigResolveResponse::Config(_) => panic!("expected not-ready error"), + let _ = source.register(node_1.clone()); + match source.resolve(&node_1) { + ConfigResolveResponse::Config(_) => panic!("expected not-ready before threshold"), ConfigResolveResponse::Error(error) => { - assert!(matches!(error.code, CfgsyncErrorCode::NotReady)) + assert!(matches!(error.code, CfgsyncErrorCode::NotReady)); } } - let _ = source.register(node_b); + let _ = source.register(node_2.clone()); - match source.resolve(&node_a) { + match source.resolve(&node_1) { ConfigResolveResponse::Config(payload) => { - assert_eq!(payload.files()[0].content, "peer_count: 2"); - assert_eq!(payload.files()[1].content, "shared_count: 2"); + assert_eq!(payload.files.len(), 2); + assert_eq!(payload.files[0].path, "/config.yaml"); + assert_eq!(payload.files[1].path, "/shared.yaml"); } ConfigResolveResponse::Error(error) => panic!("expected config, got {error}"), } } struct CountingSnapshotMaterializer { - calls: std::sync::Arc, + calls: AtomicUsize, } impl RegistrationSnapshotMaterializer for CountingSnapshotMaterializer { @@ -412,36 +220,30 @@ mod tests { self.calls.fetch_add(1, Ordering::SeqCst); Ok(MaterializationResult::ready( - MaterializedArtifacts::from_catalog(NodeArtifactsCatalog::new( - registrations - .iter() - .map(|registration| NodeArtifacts { - identifier: registration.identifier.clone(), - files: vec![ArtifactFile::new("/config.yaml", "cached: true")], - }) - .collect(), - )), + MaterializedArtifacts::from_nodes(registrations.iter().map(|registration| { + ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml", + format!("id: {}", registration.identifier), + )]), + ) + })), )) } } #[test] fn cached_snapshot_materializer_reuses_previous_result() { - let calls = std::sync::Arc::new(AtomicUsize::new(0)); - let source = SnapshotConfigSource::new(CachedSnapshotMaterializer::new( + let source = RegistrationConfigSource::new(CachedSnapshotMaterializer::new( CountingSnapshotMaterializer { - calls: std::sync::Arc::clone(&calls), + calls: AtomicUsize::new(0), }, )); - let node_a = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("parse ip")); - let node_b = NodeRegistration::new("node-b", "127.0.0.2".parse().expect("parse ip")); + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); - let _ = source.register(node_a.clone()); - let _ = source.register(node_b.clone()); - - let _ = source.resolve(&node_a); - let _ = source.resolve(&node_b); - - assert_eq!(calls.load(Ordering::SeqCst), 1); + let _ = source.register(registration.clone()); + let _ = source.resolve(®istration); + let _ = source.resolve(®istration); } } diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index 547b182..a05d244 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -13,16 +13,17 @@ version = { workspace = true } workspace = true [dependencies] -anyhow = "1" -axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } -cfgsync-adapter = { workspace = true } -cfgsync-core = { workspace = true } -clap = { version = "4", features = ["derive"] } -serde = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } -tracing = { workspace = true } +anyhow = "1" +axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } +cfgsync-adapter = { workspace = true } +cfgsync-artifacts = { workspace = true } +cfgsync-core = { workspace = true } +clap = { version = "4", features = ["derive"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 9f788d4..3bf20e8 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -3,9 +3,10 @@ use std::{fs, path::Path, sync::Arc}; use anyhow::Context as _; use axum::Router; use cfgsync_adapter::{ - ArtifactSet, CachedSnapshotMaterializer, MaterializedArtifacts, MaterializedArtifactsSink, - PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, SnapshotConfigSource, + CachedSnapshotMaterializer, MaterializedArtifacts, MaterializedArtifactsSink, + PersistingSnapshotMaterializer, RegistrationConfigSource, RegistrationSnapshotMaterializer, }; +use cfgsync_artifacts::ArtifactSet; use cfgsync_core::{ BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, RunCfgsyncError, build_cfgsync_router, serve_cfgsync, @@ -148,7 +149,7 @@ fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result> { let bundle = load_bundle_yaml(bundle_path)?; let materialized = build_materialized_artifacts(bundle); - let provider = SnapshotConfigSource::new(materialized); + let provider = RegistrationConfigSource::new(materialized); Ok(Arc::new(provider)) } @@ -165,16 +166,9 @@ fn build_materialized_artifacts(bundle: NodeArtifactsBundle) -> MaterializedArti let nodes = bundle .nodes .into_iter() - .map(|node| cfgsync_adapter::NodeArtifacts { - identifier: node.identifier, - files: node.files, - }) - .collect(); + .map(|node| (node.identifier, ArtifactSet::new(node.files))); - MaterializedArtifacts::new( - cfgsync_adapter::NodeArtifactsCatalog::new(nodes), - ArtifactSet::new(bundle.shared_files), - ) + MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(bundle.shared_files)) } fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { @@ -213,7 +207,7 @@ pub fn build_snapshot_cfgsync_router(materializer: M) -> Router where M: RegistrationSnapshotMaterializer + 'static, { - let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new(materializer)); + let provider = RegistrationConfigSource::new(CachedSnapshotMaterializer::new(materializer)); build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) } @@ -227,7 +221,7 @@ where M: RegistrationSnapshotMaterializer + 'static, S: MaterializedArtifactsSink + 'static, { - let provider = SnapshotConfigSource::new(CachedSnapshotMaterializer::new( + let provider = RegistrationConfigSource::new(CachedSnapshotMaterializer::new( PersistingSnapshotMaterializer::new(materializer, sink), )); diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 968cfc2..8bf2162 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use cfgsync_adapter::{DeploymentAdapter, build_node_artifact_catalog}; +use cfgsync_adapter::{DeploymentAdapter, build_materialized_artifacts}; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::{ NodeArtifactsBundle, NodeArtifactsBundleEntry, @@ -49,39 +49,42 @@ fn build_cfgsync_bundle( topology: &E::Deployment, hostnames: &[String], ) -> Result { - let nodes = build_node_artifact_catalog::(topology, hostnames)?.into_nodes(); - let nodes = nodes - .into_iter() - .map(|node| NodeArtifactsBundleEntry { - identifier: node.identifier, - files: node.files, + let materialized = build_materialized_artifacts::(topology, hostnames)?; + let nodes = materialized + .iter() + .map(|(identifier, artifacts)| NodeArtifactsBundleEntry { + identifier: identifier.to_owned(), + files: artifacts.files.clone(), }) .collect(); - Ok(NodeArtifactsBundle::new(nodes)) + Ok(NodeArtifactsBundle::new(nodes).with_shared_files(materialized.shared().files.clone())) } fn append_deployment_files(bundle: &mut NodeArtifactsBundle) -> Result<()> { - for node in &mut bundle.nodes { - if has_file_path(node, "/deployment.yaml") { - continue; - } - - let config_content = - config_file_content(node).ok_or_else(|| BundleRenderError::MissingConfigFile { - identifier: node.identifier.clone(), - })?; - let deployment_yaml = extract_yaml_key(&config_content, "deployment")?; - - node.files - .push(build_bundle_file("/deployment.yaml", deployment_yaml)); + if has_shared_file_path(bundle, "/deployment.yaml") { + return Ok(()); } + let Some(node) = bundle.nodes.first() else { + return Ok(()); + }; + + let config_content = + config_file_content(node).ok_or_else(|| BundleRenderError::MissingConfigFile { + identifier: node.identifier.clone(), + })?; + let deployment_yaml = extract_yaml_key(&config_content, "deployment")?; + + bundle + .shared_files + .push(build_bundle_file("/deployment.yaml", deployment_yaml)); + Ok(()) } -fn has_file_path(node: &NodeArtifactsBundleEntry, path: &str) -> bool { - node.files.iter().any(|file| file.path == path) +fn has_shared_file_path(bundle: &NodeArtifactsBundle, path: &str) -> bool { + bundle.shared_files.iter().any(|file| file.path == path) } fn config_file_content(node: &NodeArtifactsBundleEntry) -> Option { diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index 8ed5910..0d4168a 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ pub use cfgsync_adapter::*; #[doc(hidden)] pub use cfgsync_adapter::{ - DeploymentAdapter as CfgsyncEnv, build_node_artifact_catalog as build_cfgsync_node_catalog, + DeploymentAdapter as CfgsyncEnv, build_materialized_artifacts as build_cfgsync_node_catalog, }; From b3f1f20ec8b8b3a5ed92335dc7b064897727c57d Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:53:59 +0100 Subject: [PATCH 24/38] Document cfgsync deployment adapter --- cfgsync/adapter/src/deployment.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cfgsync/adapter/src/deployment.rs b/cfgsync/adapter/src/deployment.rs index ba8c272..c50c1de 100644 --- a/cfgsync/adapter/src/deployment.rs +++ b/cfgsync/adapter/src/deployment.rs @@ -8,20 +8,31 @@ use crate::MaterializedArtifacts; /// Adapter contract for converting an application deployment model into /// node-specific serialized config payloads. pub trait DeploymentAdapter { + /// Application-specific deployment model that cfgsync renders from. type Deployment; + /// One node entry inside the deployment model. type Node; + /// In-memory node config type produced before serialization. type NodeConfig; + /// Adapter-specific failure type raised while building or rewriting + /// configs. type Error: Error + Send + Sync + 'static; + /// Returns the ordered node list that cfgsync should materialize. fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; + /// Returns the stable identifier cfgsync should use for this node. fn node_identifier(index: usize, node: &Self::Node) -> String; + /// Builds the initial in-memory config for one node before hostname + /// rewriting is applied. fn build_node_config( deployment: &Self::Deployment, node: &Self::Node, ) -> Result; + /// Rewrites any inter-node references so the config can be served through + /// cfgsync using the provided hostnames. fn rewrite_for_hostnames( deployment: &Self::Deployment, node_index: usize, @@ -29,6 +40,8 @@ pub trait DeploymentAdapter { config: &mut Self::NodeConfig, ) -> Result<(), Self::Error>; + /// Serializes the final node config into the file content cfgsync should + /// deliver. fn serialize_node_config(config: &Self::NodeConfig) -> Result; } From fc58b10cf1529dd9dba60d37cb38c74936885c6c Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:55:08 +0100 Subject: [PATCH 25/38] Rewrite cfgsync README for clarity --- cfgsync/README.md | 257 ++++++++++++++++++++++------------------------ 1 file changed, 120 insertions(+), 137 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 7431676..8f01ba1 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -2,33 +2,46 @@ `cfgsync` is a small library stack for node registration and config artifact delivery. -It is designed for distributed test and bootstrap flows where nodes need to: +It is meant for distributed bootstrap flows where nodes: - register themselves with a config service -- wait until config is ready -- fetch one artifact payload containing the files they need +- wait until artifacts are ready +- fetch one payload containing the files they need - write those files locally and continue startup -The important boundary is: +The design boundary is simple: + - `cfgsync` owns transport, registration storage, polling, and artifact serving - the application adapter owns readiness policy and artifact generation -That keeps `cfgsync` generic while still supporting app-specific bootstrap logic. +That keeps the library reusable without forcing application-specific bootstrap logic into core crates. -## Crates +## The model + +There are two ways to use `cfgsync`. + +The simpler path is static bundle serving. In that mode, all artifacts are known ahead of time and the server just serves a precomputed bundle. + +The more general path is registration-backed serving. In that mode, nodes register first, the server builds a stable registration snapshot, and the application materializer decides when artifacts are ready and what should be served. + +Both paths use the same client protocol and the same artifact payload shape. The difference is only where artifacts come from. + +## Crate roles ### `cfgsync-artifacts` -Data types for delivered files. -Primary types: +This crate defines the file-level data model: + - `ArtifactFile` - `ArtifactSet` -Use this crate when you only need to talk about files and file collections. +If you only need to talk about files and file groups, this is the crate you use. ### `cfgsync-core` -Protocol and server/client building blocks. -Primary types: +This crate defines the protocol and the low-level server/client pieces. + +Important types: + - `NodeRegistration` - `RegistrationPayload` - `NodeArtifactsPayload` @@ -37,93 +50,101 @@ Primary types: - `StaticConfigSource` - `BundleConfigSource` - `CfgsyncServerState` -- `build_cfgsync_router(...)` -- `serve_cfgsync(...)` -This crate defines the generic HTTP contract: +It also defines the generic HTTP contract: + - `POST /register` - `POST /node` -Typical flow: -1. client registers a node -2. client requests its artifacts -3. server returns either: - - `Ready` payload - - `NotReady` - - `Missing` +The normal flow is: + +1. a node registers +2. the node asks for its artifacts +3. the server responds with either a payload, `NotReady`, or `Missing` ### `cfgsync-adapter` -Adapter-facing materialization layer. -Primary types: -- `MaterializedArtifacts` +This crate is the application-facing integration layer. + +The core concepts are: + - `RegistrationSnapshot` - `RegistrationSnapshotMaterializer` +- `MaterializedArtifacts` +- `MaterializationResult` + +The main question for an adapter is: + +“Given the current registration snapshot, are artifacts ready yet, and if so, what should be served?” + +The crate also includes a few reusable wrappers: + - `CachedSnapshotMaterializer` +- `PersistingSnapshotMaterializer` - `RegistrationConfigSource` -- `DeploymentAdapter` -This crate is where app-specific bootstrap logic plugs in. - -The main pattern is snapshot materialization: -- `RegistrationSnapshotMaterializer` - -Use it when readiness depends on the full registered set. +`DeploymentAdapter` is still available as a helper for static deployment-driven rendering, but it is a secondary API. The main cfgsync model is registration-backed materialization. ### `cfgsync-runtime` -Small runtime helpers and binaries. -Primary exports: -- `ArtifactOutputMap` -- `register_and_fetch_artifacts(...)` -- `fetch_and_write_artifacts(...)` -- `run_cfgsync_client_from_env()` -- `CfgsyncServerConfig` -- `CfgsyncServingMode` -- `serve_cfgsync_from_config(...)` -- `serve_snapshot_cfgsync(...)` +This crate provides operational helpers and binaries. -This crate is for operational wiring, not for app-specific logic. +It includes: -## Design +- client-side fetch/write helpers +- server config loading +- direct server entrypoints for materializers -There are two serving models. +Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. -### 1. Static bundle serving -Config is precomputed up front. +## Artifact model -Use: -- `NodeArtifactsBundle` -- `BundleConfigSource` -- `CfgsyncServingMode::Bundle` +`cfgsync` serves one node request at a time, but the adapter usually thinks in snapshots. -This is the simplest path when the full artifact set is already known. +The adapter produces `MaterializedArtifacts`, which contain: -### 2. Registration-backed serving -Config is produced from node registrations. +- node-local artifacts keyed by node identifier +- shared artifacts delivered alongside every node -Use: -- `RegistrationSnapshotMaterializer` -- `CachedSnapshotMaterializer` -- `RegistrationConfigSource` -- `serve_snapshot_cfgsync(...)` +When one node requests config, cfgsync resolves that node’s local files, merges in the shared files, and returns a single payload. -This is the right model when config readiness depends on the current registered set. +This is why applications do not need separate “node config” and “shared config” endpoints unless they want legacy compatibility. -## Public API shape +## Registration-backed flow -### Register a node +This is the main integration path. -Nodes register with: -- stable identifier -- IPv4 address +The node sends a `NodeRegistration` containing: + +- a stable identifier +- an IP address - optional typed application metadata -Application metadata is carried as an opaque serialized payload: -- generic in `cfgsync` -- interpreted only by the adapter +That metadata is opaque to cfgsync itself. It is only interpreted by the application adapter. -Example: +The server stores registrations and builds a `RegistrationSnapshot`. The application implements `RegistrationSnapshotMaterializer` and decides: + +- whether the current snapshot is ready +- which node-local artifacts should be produced +- which shared artifacts should accompany them + +If the materializer returns `NotReady`, cfgsync responds accordingly and the client can retry later. If it returns `Ready`, cfgsync serves the resolved artifact payload. + +## Static bundle flow + +Static bundle mode still exists because it is useful when artifacts are already known. + +That is appropriate for: + +- fully precomputed topologies +- deterministic fixtures +- test setups where no runtime coordination is needed + +In that mode, cfgsync serves from `NodeArtifactsBundle` through `BundleConfigSource`. + +Bundle mode is useful, but it is not the defining idea of the library anymore. The primary model is registration-backed materialization. + +## Example: typed registration metadata ```rust use cfgsync_core::NodeRegistration; @@ -141,7 +162,7 @@ let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().unwrap()) })?; ``` -### Materialize from the registration snapshot +## Example: snapshot materializer ```rust use cfgsync_adapter::{ @@ -161,15 +182,15 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { return Ok(MaterializationResult::NotReady); } - let nodes = registrations - .iter() - .map(|registration| ( + let nodes = registrations.iter().map(|registration| { + ( registration.identifier.clone(), ArtifactSet::new(vec![ArtifactFile::new( "/config.yaml", format!("id: {}\n", registration.identifier), )]), - )); + ) + }); Ok(MaterializationResult::ready( MaterializedArtifacts::from_nodes(nodes), @@ -178,7 +199,7 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { } ``` -### Serve registration-backed cfgsync +## Example: serving cfgsync ```rust use cfgsync_runtime::serve_snapshot_cfgsync; @@ -189,20 +210,7 @@ serve_snapshot_cfgsync(4400, MyMaterializer).await?; # } ``` -### Fetch from a client - -```rust -use cfgsync_core::CfgsyncClient; - -# async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { -let client = CfgsyncClient::new("http://127.0.0.1:4400"); -client.register_node(®istration).await?; -let payload = client.fetch_node_config(®istration).await?; -# Ok(()) -# } -``` - -### Fetch and write artifacts with runtime helpers +## Example: fetching artifacts ```rust use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts}; @@ -219,70 +227,45 @@ fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).awai ## What belongs in the adapter -Put these in your app adapter: +Keep these in your application adapter: + - registration payload type - readiness rule - conversion from registration snapshot to artifacts -- shared file generation if your app needs shared files +- shared artifact generation if your app needs it -Examples: -- wait for `n` initial nodes -- derive peer lists from registrations -- build node-local config files -- include shared deployment/config files in every node payload +Typical examples are: -## What does **not** belong in `cfgsync-core` +- waiting for `n` initial nodes +- deriving peer lists from registrations +- building node-local config files +- generating one shared deployment file for all nodes -Do not put these into generic cfgsync: -- app-specific topology rules -- domain-specific genesis/deployment generation -- app-specific command/state-machine logic -- service-specific semantics for what a node means +## What does not belong in cfgsync core + +Do not push these into generic cfgsync: + +- topology semantics specific to one application +- genesis or deployment generation specific to one protocol +- application-specific command/state-machine logic +- domain-specific ideas of what a node means Those belong in the adapter or the consuming application. -## Recommended integration model +## Recommended integration path -If you are integrating a new app, start here: +If you are integrating a new app, the shortest sensible path is: 1. define a typed registration payload 2. implement `RegistrationSnapshotMaterializer` -3. return one artifact payload per node -4. include shared files inside that payload if your app needs them -5. serve with `serve_snapshot_cfgsync(...)` -6. use `CfgsyncClient` on the node side -7. use runtime helpers if you want generic client-side file writing instead of custom dispatch code +3. return node-local and optional shared artifacts +4. serve them with `serve_snapshot_cfgsync(...)` +5. use `CfgsyncClient` or the runtime helpers on the node side -This model keeps the generic library small and keeps application semantics where they belong. +That gives you the main library value without forcing extra application logic into cfgsync itself. ## Compatibility -The primary surface is the one reexported from crate roots. +The primary supported surface is what is reexported from the crate roots. -There are hidden compatibility aliases in some crates to keep older internal consumers building, but they are not the recommended API for new integrations. - -## Runtime config files - -`serve_cfgsync_from_config(...)` is for runtime-config-driven serving. - -Today it supports: -- static bundle serving -- registration serving from a prebuilt artifact catalog - -If your app has a real registration-backed materializer, prefer the direct runtime API: -- `serve_snapshot_cfgsync(...)` - -That keeps application behavior in adapter code instead of trying to encode it into YAML. - -## Current status - -`cfgsync` is suitable for: -- internal reuse across multiple apps -- registration-backed bootstrap flows -- static precomputed artifact serving - -It is not intended to be: -- a generic orchestration framework -- a topology engine -- a secret-management system -- an app-specific bootstrap policy layer +Some older names and compatibility paths still exist internally, but they are not the intended public API. From 4d19570a717f1c212fba7df7e4e6595580ac22b5 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 07:57:27 +0100 Subject: [PATCH 26/38] Make cfgsync README more prose-driven --- cfgsync/README.md | 137 ++++++---------------------------------------- 1 file changed, 16 insertions(+), 121 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 8f01ba1..56ace1e 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -2,18 +2,9 @@ `cfgsync` is a small library stack for node registration and config artifact delivery. -It is meant for distributed bootstrap flows where nodes: -- register themselves with a config service -- wait until artifacts are ready -- fetch one payload containing the files they need -- write those files locally and continue startup +It is meant for distributed bootstrap flows where nodes register themselves with a config service, wait until artifacts are ready, fetch one payload containing the files they need, and then write those files locally before continuing startup. -The design boundary is simple: - -- `cfgsync` owns transport, registration storage, polling, and artifact serving -- the application adapter owns readiness policy and artifact generation - -That keeps the library reusable without forcing application-specific bootstrap logic into core crates. +The boundary is simple. `cfgsync` owns transport, registration storage, polling, and artifact serving. The application adapter owns readiness policy and artifact generation. That keeps the library reusable without forcing application-specific bootstrap logic into core crates. ## The model @@ -29,84 +20,31 @@ Both paths use the same client protocol and the same artifact payload shape. The ### `cfgsync-artifacts` -This crate defines the file-level data model: - -- `ArtifactFile` -- `ArtifactSet` - -If you only need to talk about files and file groups, this is the crate you use. +This crate defines the file-level data model. `ArtifactFile` represents one file and `ArtifactSet` represents a group of files delivered together. If you only need to talk about files and file groups, this is the crate you use. ### `cfgsync-core` -This crate defines the protocol and the low-level server/client pieces. +This crate defines the protocol and the low-level server/client pieces. The central types are `NodeRegistration`, `RegistrationPayload`, `NodeArtifactsPayload`, `CfgsyncClient`, and the `NodeConfigSource` implementations used by the server. -Important types: - -- `NodeRegistration` -- `RegistrationPayload` -- `NodeArtifactsPayload` -- `CfgsyncClient` -- `NodeConfigSource` -- `StaticConfigSource` -- `BundleConfigSource` -- `CfgsyncServerState` - -It also defines the generic HTTP contract: - -- `POST /register` -- `POST /node` - -The normal flow is: - -1. a node registers -2. the node asks for its artifacts -3. the server responds with either a payload, `NotReady`, or `Missing` +It also defines the generic HTTP contract: nodes `POST /register`, then `POST /node` to fetch artifacts. The server responds with either a payload, `NotReady`, or `Missing`. ### `cfgsync-adapter` -This crate is the application-facing integration layer. +This crate is the application-facing integration layer. The main concepts are `RegistrationSnapshot`, `RegistrationSnapshotMaterializer`, `MaterializedArtifacts`, and `MaterializationResult`. -The core concepts are: +The adapter answers one question: given the current registration snapshot, are artifacts ready yet, and if so, what should be served? -- `RegistrationSnapshot` -- `RegistrationSnapshotMaterializer` -- `MaterializedArtifacts` -- `MaterializationResult` - -The main question for an adapter is: - -“Given the current registration snapshot, are artifacts ready yet, and if so, what should be served?” - -The crate also includes a few reusable wrappers: - -- `CachedSnapshotMaterializer` -- `PersistingSnapshotMaterializer` -- `RegistrationConfigSource` - -`DeploymentAdapter` is still available as a helper for static deployment-driven rendering, but it is a secondary API. The main cfgsync model is registration-backed materialization. +The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. `DeploymentAdapter` still exists as a helper for static deployment-driven rendering, but it is a secondary API. The main cfgsync model is registration-backed materialization. ### `cfgsync-runtime` -This crate provides operational helpers and binaries. - -It includes: - -- client-side fetch/write helpers -- server config loading -- direct server entrypoints for materializers - -Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. +This crate provides operational helpers and binaries. It includes client-side fetch/write helpers, server config loading, and direct server entrypoints for materializers. Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. ## Artifact model `cfgsync` serves one node request at a time, but the adapter usually thinks in snapshots. -The adapter produces `MaterializedArtifacts`, which contain: - -- node-local artifacts keyed by node identifier -- shared artifacts delivered alongside every node - -When one node requests config, cfgsync resolves that node’s local files, merges in the shared files, and returns a single payload. +The adapter produces `MaterializedArtifacts`, which contain node-local artifacts keyed by node identifier plus optional shared artifacts delivered alongside every node. When one node requests config, cfgsync resolves that node’s local files, merges in the shared files, and returns a single payload. This is why applications do not need separate “node config” and “shared config” endpoints unless they want legacy compatibility. @@ -114,19 +52,9 @@ This is why applications do not need separate “node config” and “shared co This is the main integration path. -The node sends a `NodeRegistration` containing: +The node sends a `NodeRegistration` containing a stable identifier, an IP address, and optional typed application metadata. That metadata is opaque to cfgsync itself and is only interpreted by the application adapter. -- a stable identifier -- an IP address -- optional typed application metadata - -That metadata is opaque to cfgsync itself. It is only interpreted by the application adapter. - -The server stores registrations and builds a `RegistrationSnapshot`. The application implements `RegistrationSnapshotMaterializer` and decides: - -- whether the current snapshot is ready -- which node-local artifacts should be produced -- which shared artifacts should accompany them +The server stores registrations and builds a `RegistrationSnapshot`. The application implements `RegistrationSnapshotMaterializer` and decides whether the current snapshot is ready, which node-local artifacts should be produced, and which shared artifacts should accompany them. If the materializer returns `NotReady`, cfgsync responds accordingly and the client can retry later. If it returns `Ready`, cfgsync serves the resolved artifact payload. @@ -134,13 +62,7 @@ If the materializer returns `NotReady`, cfgsync responds accordingly and the cli Static bundle mode still exists because it is useful when artifacts are already known. -That is appropriate for: - -- fully precomputed topologies -- deterministic fixtures -- test setups where no runtime coordination is needed - -In that mode, cfgsync serves from `NodeArtifactsBundle` through `BundleConfigSource`. +That is appropriate for fully precomputed topologies, deterministic fixtures, and test setups where no runtime coordination is needed. In that mode, cfgsync serves from `NodeArtifactsBundle` through `BundleConfigSource`. Bundle mode is useful, but it is not the defining idea of the library anymore. The primary model is registration-backed materialization. @@ -227,42 +149,15 @@ fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).awai ## What belongs in the adapter -Keep these in your application adapter: - -- registration payload type -- readiness rule -- conversion from registration snapshot to artifacts -- shared artifact generation if your app needs it - -Typical examples are: - -- waiting for `n` initial nodes -- deriving peer lists from registrations -- building node-local config files -- generating one shared deployment file for all nodes +The adapter should own the application-specific parts of bootstrap: the registration payload type, the readiness rule, the conversion from registration snapshots into artifacts, and any shared artifact generation your app needs. In practice that means things like waiting for `n` initial nodes, deriving peer lists from registrations, building node-local config files, or generating one shared deployment file for all nodes. ## What does not belong in cfgsync core -Do not push these into generic cfgsync: - -- topology semantics specific to one application -- genesis or deployment generation specific to one protocol -- application-specific command/state-machine logic -- domain-specific ideas of what a node means - -Those belong in the adapter or the consuming application. +Do not push application-specific topology semantics, genesis or deployment generation, command/state-machine logic, or domain-specific ideas of what a node means into generic cfgsync. Those belong in the adapter or the consuming application. ## Recommended integration path -If you are integrating a new app, the shortest sensible path is: - -1. define a typed registration payload -2. implement `RegistrationSnapshotMaterializer` -3. return node-local and optional shared artifacts -4. serve them with `serve_snapshot_cfgsync(...)` -5. use `CfgsyncClient` or the runtime helpers on the node side - -That gives you the main library value without forcing extra application logic into cfgsync itself. +If you are integrating a new app, the shortest sensible path is to define a typed registration payload, implement `RegistrationSnapshotMaterializer`, return node-local and optional shared artifacts, serve them with `serve_snapshot_cfgsync(...)`, and use `CfgsyncClient` or the runtime helpers on the node side. That gives you the main library value without forcing extra application logic into cfgsync itself. ## Compatibility From ec4c42244afe6d363c72d22c86dc7ce93225297d Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 08:11:25 +0100 Subject: [PATCH 27/38] Demote static cfgsync helpers --- cfgsync/README.md | 4 ++-- cfgsync/adapter/src/lib.rs | 12 +++++++++++- logos/runtime/ext/src/cfgsync/mod.rs | 2 +- testing-framework/core/src/cfgsync/mod.rs | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 56ace1e..c4fae33 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -34,7 +34,7 @@ This crate is the application-facing integration layer. The main concepts are `R The adapter answers one question: given the current registration snapshot, are artifacts ready yet, and if so, what should be served? -The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. `DeploymentAdapter` still exists as a helper for static deployment-driven rendering, but it is a secondary API. The main cfgsync model is registration-backed materialization. +The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. Static deployment-driven rendering still exists, but it lives under `cfgsync_adapter::static_deployment` as a secondary helper path. The main cfgsync model is registration-backed materialization. ### `cfgsync-runtime` @@ -64,7 +64,7 @@ Static bundle mode still exists because it is useful when artifacts are already That is appropriate for fully precomputed topologies, deterministic fixtures, and test setups where no runtime coordination is needed. In that mode, cfgsync serves from `NodeArtifactsBundle` through `BundleConfigSource`. -Bundle mode is useful, but it is not the defining idea of the library anymore. The primary model is registration-backed materialization. +Bundle mode is useful, but it is not the defining idea of the library anymore. The primary model is registration-backed materialization, and the static helpers are intentionally kept off the main adapter surface. ## Example: typed registration metadata diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index d202854..af8f9f1 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -5,10 +5,20 @@ mod registrations; mod sources; pub use artifacts::MaterializedArtifacts; -pub use deployment::{BuildCfgsyncNodesError, DeploymentAdapter, build_materialized_artifacts}; pub use materializer::{ CachedSnapshotMaterializer, DynCfgsyncError, MaterializationResult, MaterializedArtifactsSink, PersistingSnapshotMaterializer, RegistrationSnapshotMaterializer, }; pub use registrations::RegistrationSnapshot; pub use sources::RegistrationConfigSource; + +/// Static deployment helpers for precomputed cfgsync artifact generation. +/// +/// This module is intentionally secondary to the registration-backed +/// materializer flow. Use it when artifacts are already determined by a +/// deployment plan and do not need runtime registration to become available. +pub mod static_deployment { + pub use super::deployment::{ + BuildCfgsyncNodesError, DeploymentAdapter, build_materialized_artifacts, + }; +} diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 8bf2162..1e929b8 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use cfgsync_adapter::{DeploymentAdapter, build_materialized_artifacts}; +use cfgsync_adapter::static_deployment::{DeploymentAdapter, build_materialized_artifacts}; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::{ NodeArtifactsBundle, NodeArtifactsBundleEntry, diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index 0d4168a..207265a 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -1,5 +1,5 @@ -pub use cfgsync_adapter::*; #[doc(hidden)] -pub use cfgsync_adapter::{ +pub use cfgsync_adapter::static_deployment::{ DeploymentAdapter as CfgsyncEnv, build_materialized_artifacts as build_cfgsync_node_catalog, }; +pub use cfgsync_adapter::*; From cdcb475975c9a5b355225ef5912fe4795b4f0719 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 08:27:44 +0100 Subject: [PATCH 28/38] Serve precomputed cfgsync artifacts directly --- Cargo.lock | 2 +- cfgsync/adapter/src/artifacts.rs | 3 +- cfgsync/core/src/lib.rs | 2 +- cfgsync/core/src/render.rs | 23 ++++---- cfgsync/runtime/Cargo.toml | 21 ++++--- cfgsync/runtime/src/server.rs | 65 +++++++++++--------- logos/runtime/ext/Cargo.toml | 1 + logos/runtime/ext/src/cfgsync/mod.rs | 88 ++++++++++++---------------- logos/runtime/ext/src/compose_env.rs | 6 +- logos/runtime/ext/src/k8s_env.rs | 24 ++++---- 10 files changed, 116 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9655dc0..cfb6ae8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,7 +958,6 @@ dependencies = [ "anyhow", "axum", "cfgsync-adapter", - "cfgsync-artifacts", "cfgsync-core", "clap", "serde", @@ -2920,6 +2919,7 @@ dependencies = [ "anyhow", "async-trait", "cfgsync-adapter", + "cfgsync-artifacts", "cfgsync-core", "kube", "logos-blockchain-http-api-common", diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs index 024dad6..3b7c76c 100644 --- a/cfgsync/adapter/src/artifacts.rs +++ b/cfgsync/adapter/src/artifacts.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; +use serde::{Deserialize, Serialize}; /// Fully materialized cfgsync artifacts for a registration set. /// /// `nodes` holds the node-local files keyed by stable node identifier. /// `shared` holds files that should be delivered alongside every node. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MaterializedArtifacts { nodes: HashMap, shared: ArtifactSet, diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index b7664bb..4e01aad 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -16,7 +16,7 @@ pub use protocol::{ }; pub use render::{ CfgsyncConfigOverrides, CfgsyncOutputPaths, RenderedCfgsync, apply_cfgsync_overrides, - apply_timeout_floor, ensure_bundle_path, load_cfgsync_template_yaml, + apply_timeout_floor, ensure_artifacts_path, load_cfgsync_template_yaml, render_cfgsync_yaml_from_template, write_rendered_cfgsync, }; pub use server::{CfgsyncServerState, RunCfgsyncError, build_cfgsync_router, serve_cfgsync}; diff --git a/cfgsync/core/src/render.rs b/cfgsync/core/src/render.rs index b963a99..ec99953 100644 --- a/cfgsync/core/src/render.rs +++ b/cfgsync/core/src/render.rs @@ -9,8 +9,8 @@ use thiserror::Error; pub struct RenderedCfgsync { /// Serialized cfgsync server config YAML. pub config_yaml: String, - /// Serialized node bundle YAML. - pub bundle_yaml: String, + /// Serialized precomputed artifact YAML used by cfgsync runtime. + pub artifacts_yaml: String, } /// Output paths used when materializing rendered cfgsync files. @@ -18,21 +18,22 @@ pub struct RenderedCfgsync { pub struct CfgsyncOutputPaths<'a> { /// Output path for the rendered server config YAML. pub config_path: &'a Path, - /// Output path for the rendered static bundle YAML. - pub bundle_path: &'a Path, + /// Output path for the rendered precomputed artifacts YAML. + pub artifacts_path: &'a Path, } -/// Ensures bundle path override exists, defaulting to output bundle file name. -pub fn ensure_bundle_path(bundle_path: &mut Option, output_bundle_path: &Path) { - if bundle_path.is_some() { +/// Ensures artifacts path override exists, defaulting to the output artifacts +/// file name. +pub fn ensure_artifacts_path(artifacts_path: &mut Option, output_artifacts_path: &Path) { + if artifacts_path.is_some() { return; } - *bundle_path = Some( - output_bundle_path + *artifacts_path = Some( + output_artifacts_path .file_name() .and_then(|name| name.to_str()) - .unwrap_or("cfgsync.bundle.yaml") + .unwrap_or("cfgsync.artifacts.yaml") .to_string(), ); } @@ -50,7 +51,7 @@ pub fn write_rendered_cfgsync( output: CfgsyncOutputPaths<'_>, ) -> Result<()> { fs::write(output.config_path, &rendered.config_yaml)?; - fs::write(output.bundle_path, &rendered.bundle_yaml)?; + fs::write(output.artifacts_path, &rendered.artifacts_yaml)?; Ok(()) } diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index a05d244..547b182 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -13,17 +13,16 @@ version = { workspace = true } workspace = true [dependencies] -anyhow = "1" -axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } -cfgsync-adapter = { workspace = true } -cfgsync-artifacts = { workspace = true } -cfgsync-core = { workspace = true } -clap = { version = "4", features = ["derive"] } -serde = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } -tracing = { workspace = true } +anyhow = "1" +axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } +cfgsync-adapter = { workspace = true } +cfgsync-core = { workspace = true } +clap = { version = "4", features = ["derive"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 3bf20e8..72a44ea 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -6,9 +6,8 @@ use cfgsync_adapter::{ CachedSnapshotMaterializer, MaterializedArtifacts, MaterializedArtifactsSink, PersistingSnapshotMaterializer, RegistrationConfigSource, RegistrationSnapshotMaterializer, }; -use cfgsync_artifacts::ArtifactSet; use cfgsync_core::{ - BundleConfigSource, CfgsyncServerState, NodeArtifactsBundle, NodeConfigSource, RunCfgsyncError, + BundleConfigSource, CfgsyncServerState, NodeConfigSource, RunCfgsyncError, build_cfgsync_router, serve_cfgsync, }; use serde::{Deserialize, de::Error as _}; @@ -27,7 +26,7 @@ pub struct CfgsyncServerConfig { /// /// This type is intentionally runtime-oriented: /// - `Bundle` serves a static precomputed bundle directly -/// - `RegistrationBundle` serves a precomputed bundle through the registration +/// - `Registration` serves precomputed artifacts through the registration /// protocol, which is useful when the consumer wants clients to register /// before receiving already-materialized artifacts #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -35,8 +34,12 @@ pub struct CfgsyncServerConfig { pub enum CfgsyncServerSource { /// Serve a static precomputed artifact bundle directly. Bundle { bundle_path: String }, - /// Require node registration before serving artifacts from a static bundle. - RegistrationBundle { bundle_path: String }, + /// Require node registration before serving precomputed artifacts. + #[serde(alias = "registration_bundle")] + Registration { + #[serde(alias = "bundle_path")] + artifacts_path: String, + }, } #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] @@ -107,11 +110,11 @@ impl CfgsyncServerConfig { /// Builds a config that serves a static bundle behind the registration /// flow. #[must_use] - pub fn for_registration_bundle(port: u16, bundle_path: impl Into) -> Self { + pub fn for_registration(port: u16, artifacts_path: impl Into) -> Self { Self { port, - source: CfgsyncServerSource::RegistrationBundle { - bundle_path: bundle_path.into(), + source: CfgsyncServerSource::Registration { + artifacts_path: artifacts_path.into(), }, } } @@ -120,7 +123,9 @@ impl CfgsyncServerConfig { let source = match (raw.source, raw.bundle_path, raw.serving_mode) { (Some(source), _, _) => source, (None, Some(bundle_path), Some(LegacyServingMode::Registration)) => { - CfgsyncServerSource::RegistrationBundle { bundle_path } + CfgsyncServerSource::Registration { + artifacts_path: bundle_path, + } } (None, Some(bundle_path), None | Some(LegacyServingMode::Bundle)) => { CfgsyncServerSource::Bundle { bundle_path } @@ -146,29 +151,29 @@ fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result anyhow::Result> { - let bundle = load_bundle_yaml(bundle_path)?; - let materialized = build_materialized_artifacts(bundle); +fn load_registration_source(artifacts_path: &Path) -> anyhow::Result> { + let materialized = load_materialized_artifacts_yaml(artifacts_path)?; let provider = RegistrationConfigSource::new(materialized); Ok(Arc::new(provider)) } -fn load_bundle_yaml(bundle_path: &Path) -> anyhow::Result { - let raw = fs::read_to_string(bundle_path) - .with_context(|| format!("reading cfgsync bundle from {}", bundle_path.display()))?; +fn load_materialized_artifacts_yaml( + artifacts_path: &Path, +) -> anyhow::Result { + let raw = fs::read_to_string(artifacts_path).with_context(|| { + format!( + "reading cfgsync materialized artifacts from {}", + artifacts_path.display() + ) + })?; - serde_yaml::from_str(&raw) - .with_context(|| format!("parsing cfgsync bundle from {}", bundle_path.display())) -} - -fn build_materialized_artifacts(bundle: NodeArtifactsBundle) -> MaterializedArtifacts { - let nodes = bundle - .nodes - .into_iter() - .map(|node| (node.identifier, ArtifactSet::new(node.files))); - - MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(bundle.shared_files)) + serde_yaml::from_str(&raw).with_context(|| { + format!( + "parsing cfgsync materialized artifacts from {}", + artifacts_path.display() + ) + }) } fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { @@ -278,7 +283,7 @@ fn build_server_state( ) -> anyhow::Result { let repo = match &config.source { CfgsyncServerSource::Bundle { .. } => load_bundle_provider(source_path)?, - CfgsyncServerSource::RegistrationBundle { .. } => load_registration_source(source_path)?, + CfgsyncServerSource::Registration { .. } => load_registration_source(source_path)?, }; Ok(CfgsyncServerState::new(repo)) @@ -286,9 +291,11 @@ fn build_server_state( fn resolve_source_path(config_path: &Path, source: &CfgsyncServerSource) -> std::path::PathBuf { match source { - CfgsyncServerSource::Bundle { bundle_path } - | CfgsyncServerSource::RegistrationBundle { bundle_path } => { + CfgsyncServerSource::Bundle { bundle_path } => { resolve_bundle_path(config_path, bundle_path) } + CfgsyncServerSource::Registration { artifacts_path } => { + resolve_bundle_path(config_path, artifacts_path) + } } } diff --git a/logos/runtime/ext/Cargo.toml b/logos/runtime/ext/Cargo.toml index 05dd243..b317f39 100644 --- a/logos/runtime/ext/Cargo.toml +++ b/logos/runtime/ext/Cargo.toml @@ -8,6 +8,7 @@ version = { workspace = true } [dependencies] # Workspace crates cfgsync-adapter = { workspace = true } +cfgsync-artifacts = { workspace = true } cfgsync-core = { workspace = true } lb-framework = { workspace = true } testing-framework-core = { workspace = true } diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 1e929b8..7b86f67 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,12 +1,10 @@ use anyhow::Result; use cfgsync_adapter::static_deployment::{DeploymentAdapter, build_materialized_artifacts}; +use cfgsync_artifacts::ArtifactFile; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; -use cfgsync_core::{ - NodeArtifactsBundle, NodeArtifactsBundleEntry, - render::{ - CfgsyncConfigOverrides, RenderedCfgsync, ensure_bundle_path, - render_cfgsync_yaml_from_template, write_rendered_cfgsync, - }, +use cfgsync_core::render::{ + CfgsyncConfigOverrides, RenderedCfgsync, ensure_artifacts_path, + render_cfgsync_yaml_from_template, write_rendered_cfgsync, }; use reqwest::Url; use serde_yaml::{Mapping, Value}; @@ -14,7 +12,7 @@ use thiserror::Error; pub(crate) struct CfgsyncRenderOptions { pub port: Option, - pub bundle_path: Option, + pub artifacts_path: Option, pub min_timeout_secs: Option, pub metrics_otlp_ingest_url: Option, } @@ -35,69 +33,59 @@ pub(crate) fn render_cfgsync_from_template( let cfg = build_cfgsync_server_config(); let overrides = build_overrides::(topology, options); let config_yaml = render_cfgsync_yaml_from_template(cfg, &overrides)?; - let mut bundle = build_cfgsync_bundle::(topology, hostnames)?; - append_deployment_files(&mut bundle)?; - let bundle_yaml = serde_yaml::to_string(&bundle)?; + let mut materialized = build_materialized_artifacts::(topology, hostnames)?; + append_deployment_files(&mut materialized)?; + let artifacts_yaml = serde_yaml::to_string(&materialized)?; Ok(RenderedCfgsync { config_yaml, - bundle_yaml, + artifacts_yaml, }) } -fn build_cfgsync_bundle( - topology: &E::Deployment, - hostnames: &[String], -) -> Result { - let materialized = build_materialized_artifacts::(topology, hostnames)?; - let nodes = materialized - .iter() - .map(|(identifier, artifacts)| NodeArtifactsBundleEntry { - identifier: identifier.to_owned(), - files: artifacts.files.clone(), - }) - .collect(); - - Ok(NodeArtifactsBundle::new(nodes).with_shared_files(materialized.shared().files.clone())) -} - -fn append_deployment_files(bundle: &mut NodeArtifactsBundle) -> Result<()> { - if has_shared_file_path(bundle, "/deployment.yaml") { +fn append_deployment_files( + materialized: &mut cfgsync_adapter::MaterializedArtifacts, +) -> Result<()> { + if has_shared_file_path(materialized, "/deployment.yaml") { return Ok(()); } - let Some(node) = bundle.nodes.first() else { + let Some((identifier, artifacts)) = materialized.iter().next() else { return Ok(()); }; let config_content = - config_file_content(node).ok_or_else(|| BundleRenderError::MissingConfigFile { - identifier: node.identifier.clone(), + config_file_content(artifacts).ok_or_else(|| BundleRenderError::MissingConfigFile { + identifier: identifier.to_owned(), })?; let deployment_yaml = extract_yaml_key(&config_content, "deployment")?; - bundle - .shared_files - .push(build_bundle_file("/deployment.yaml", deployment_yaml)); + let mut shared = materialized.shared().clone(); + shared + .files + .push(build_artifact_file("/deployment.yaml", deployment_yaml)); + *materialized = materialized.clone().with_shared(shared); Ok(()) } -fn has_shared_file_path(bundle: &NodeArtifactsBundle, path: &str) -> bool { - bundle.shared_files.iter().any(|file| file.path == path) +fn has_shared_file_path(materialized: &cfgsync_adapter::MaterializedArtifacts, path: &str) -> bool { + materialized + .shared() + .files + .iter() + .any(|file| file.path == path) } -fn config_file_content(node: &NodeArtifactsBundleEntry) -> Option { - node.files +fn config_file_content(artifacts: &cfgsync_artifacts::ArtifactSet) -> Option { + artifacts + .files .iter() .find_map(|file| (file.path == "/config.yaml").then_some(file.content.clone())) } -fn build_bundle_file(path: &str, content: String) -> cfgsync_core::NodeArtifactFile { - cfgsync_core::NodeArtifactFile { - path: path.to_owned(), - content, - } +fn build_artifact_file(path: &str, content: String) -> ArtifactFile { + ArtifactFile::new(path, content) } fn extract_yaml_key(content: &str, key: &str) -> Result { @@ -122,11 +110,11 @@ fn build_cfgsync_server_config() -> Value { let mut source = Mapping::new(); source.insert( Value::String("kind".to_string()), - Value::String("registration_bundle".to_string()), + Value::String("registration".to_string()), ); source.insert( - Value::String("bundle_path".to_string()), - Value::String("cfgsync.bundle.yaml".to_string()), + Value::String("artifacts_path".to_string()), + Value::String("cfgsync.artifacts.yaml".to_string()), ); root.insert(Value::String("source".to_string()), Value::Mapping(source)); @@ -140,7 +128,7 @@ pub(crate) fn render_and_write_cfgsync_from_template( mut options: CfgsyncRenderOptions, output: CfgsyncOutputPaths<'_>, ) -> Result { - ensure_bundle_path(&mut options.bundle_path, output.bundle_path); + ensure_artifacts_path(&mut options.artifacts_path, output.artifacts_path); let rendered = render_cfgsync_from_template::(topology, hostnames, options)?; write_rendered_cfgsync(&rendered, output)?; @@ -154,7 +142,7 @@ fn build_overrides( ) -> CfgsyncConfigOverrides { let CfgsyncRenderOptions { port, - bundle_path, + artifacts_path, min_timeout_secs, metrics_otlp_ingest_url, } = options; @@ -163,7 +151,7 @@ fn build_overrides( port, n_hosts: Some(E::nodes(topology).len()), timeout_floor_secs: min_timeout_secs, - bundle_path, + bundle_path: artifacts_path, metrics_otlp_ingest_url: metrics_otlp_ingest_url.map(|url| url.to_string()), } } diff --git a/logos/runtime/ext/src/compose_env.rs b/logos/runtime/ext/src/compose_env.rs index 4fa3018..b1bb1eb 100644 --- a/logos/runtime/ext/src/compose_env.rs +++ b/logos/runtime/ext/src/compose_env.rs @@ -127,7 +127,7 @@ impl ComposeDeployEnv for LbcExtEnv { options, CfgsyncOutputPaths { config_path: path, - bundle_path: &bundle_path, + artifacts_path: &bundle_path, }, )?; Ok(()) @@ -190,7 +190,7 @@ fn cfgsync_bundle_path(config_path: &Path) -> PathBuf { config_path .parent() .unwrap_or(config_path) - .join("cfgsync.bundle.yaml") + .join("cfgsync.artifacts.yaml") } fn topology_hostnames(topology: &DeploymentPlan) -> Vec { @@ -207,7 +207,7 @@ fn cfgsync_render_options( ) -> CfgsyncRenderOptions { CfgsyncRenderOptions { port: Some(port), - bundle_path: None, + artifacts_path: None, min_timeout_secs: None, metrics_otlp_ingest_url: metrics_otlp_ingest_url.cloned(), } diff --git a/logos/runtime/ext/src/k8s_env.rs b/logos/runtime/ext/src/k8s_env.rs index 15b2dc9..b88a9a0 100644 --- a/logos/runtime/ext/src/k8s_env.rs +++ b/logos/runtime/ext/src/k8s_env.rs @@ -351,24 +351,24 @@ fn render_and_write_cfgsync( tempdir: &TempDir, ) -> Result<(PathBuf, String, String), AssetsError> { let cfgsync_file = tempdir.path().join("cfgsync.yaml"); - let bundle_file = tempdir.path().join("cfgsync.bundle.yaml"); - let (cfgsync_yaml, bundle_yaml) = render_cfgsync_config( + let artifacts_file = tempdir.path().join("cfgsync.artifacts.yaml"); + let (cfgsync_yaml, artifacts_yaml) = render_cfgsync_config( topology, metrics_otlp_ingest_url, &cfgsync_file, - &bundle_file, + &artifacts_file, )?; - Ok((cfgsync_file, cfgsync_yaml, bundle_yaml)) + Ok((cfgsync_file, cfgsync_yaml, artifacts_yaml)) } fn render_and_write_values( topology: &DeploymentPlan, tempdir: &TempDir, cfgsync_yaml: &str, - bundle_yaml: &str, + artifacts_yaml: &str, ) -> Result { - let values_yaml = render_values_yaml(topology, cfgsync_yaml, bundle_yaml)?; + let values_yaml = render_values_yaml(topology, cfgsync_yaml, artifacts_yaml)?; write_temp_file(tempdir.path(), "values.yaml", values_yaml) } @@ -380,7 +380,7 @@ fn render_cfgsync_config( topology: &DeploymentPlan, metrics_otlp_ingest_url: Option<&Url>, cfgsync_file: &Path, - bundle_file: &Path, + artifacts_file: &Path, ) -> Result<(String, String), AssetsError> { let hostnames = k8s_node_hostnames(topology); let rendered = render_and_write_cfgsync_from_template::( @@ -388,18 +388,18 @@ fn render_cfgsync_config( &hostnames, CfgsyncRenderOptions { port: Some(cfgsync_port()), - bundle_path: Some("cfgsync.bundle.yaml".to_string()), + artifacts_path: Some("cfgsync.artifacts.yaml".to_string()), min_timeout_secs: Some(CFGSYNC_K8S_TIMEOUT_SECS), metrics_otlp_ingest_url: metrics_otlp_ingest_url.cloned(), }, CfgsyncOutputPaths { config_path: cfgsync_file, - bundle_path: bundle_file, + artifacts_path: artifacts_file, }, ) .map_err(|source| AssetsError::Cfgsync { source })?; - Ok((rendered.config_yaml, rendered.bundle_yaml)) + Ok((rendered.config_yaml, rendered.artifacts_yaml)) } fn k8s_node_hostnames(topology: &DeploymentPlan) -> Vec { @@ -459,9 +459,9 @@ fn helm_chart_path() -> Result { fn render_values_yaml( topology: &DeploymentPlan, cfgsync_yaml: &str, - bundle_yaml: &str, + artifacts_yaml: &str, ) -> Result { - let values = build_values(topology, cfgsync_yaml, bundle_yaml); + let values = build_values(topology, cfgsync_yaml, artifacts_yaml); serde_yaml::to_string(&values).map_err(|source| AssetsError::Values { source }) } From 320b089fbd88d07de72c87ae50ebdbbdef3f9792 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:39:16 +0100 Subject: [PATCH 29/38] Unify cfgsync around registration materialization --- Cargo.lock | 3 +- cfgsync/README.md | 28 ++-- cfgsync/adapter/Cargo.toml | 1 - cfgsync/adapter/src/deployment.rs | 124 ------------------ cfgsync/adapter/src/lib.rs | 12 -- cfgsync/core/src/render.rs | 10 +- cfgsync/runtime/Cargo.toml | 21 +-- cfgsync/runtime/examples/minimal_cfgsync.rs | 39 ++++++ cfgsync/runtime/src/lib.rs | 6 +- cfgsync/runtime/src/server.rs | 124 +++++++++--------- .../templates/cfgsync-deployment.yaml | 4 +- .../logos-runner/templates/configmap.yaml | 6 +- logos/runtime/ext/src/cfgsync/mod.rs | 12 +- logos/runtime/ext/src/compose_env.rs | 6 +- logos/runtime/ext/src/k8s_env.rs | 10 +- testing-framework/core/Cargo.toml | 1 + testing-framework/core/src/cfgsync/mod.rs | 93 ++++++++++++- testing-framework/core/src/lib.rs | 4 - 18 files changed, 241 insertions(+), 263 deletions(-) delete mode 100644 cfgsync/adapter/src/deployment.rs create mode 100644 cfgsync/runtime/examples/minimal_cfgsync.rs diff --git a/Cargo.lock b/Cargo.lock index cfb6ae8..f550368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -924,7 +924,6 @@ dependencies = [ "cfgsync-core", "serde", "serde_json", - "thiserror 2.0.18", ] [[package]] @@ -958,6 +957,7 @@ dependencies = [ "anyhow", "axum", "cfgsync-adapter", + "cfgsync-artifacts", "cfgsync-core", "clap", "serde", @@ -6557,6 +6557,7 @@ version = "0.1.0" dependencies = [ "async-trait", "cfgsync-adapter", + "cfgsync-artifacts", "futures", "parking_lot", "prometheus-http-query", diff --git a/cfgsync/README.md b/cfgsync/README.md index c4fae33..c964712 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -8,13 +8,9 @@ The boundary is simple. `cfgsync` owns transport, registration storage, polling, ## The model -There are two ways to use `cfgsync`. +There is one main way to use `cfgsync`: nodes register, the server evaluates the current registration snapshot, and the application materializer decides whether artifacts are ready yet. Once ready, cfgsync serves a single payload containing both node-local and shared files. -The simpler path is static bundle serving. In that mode, all artifacts are known ahead of time and the server just serves a precomputed bundle. - -The more general path is registration-backed serving. In that mode, nodes register first, the server builds a stable registration snapshot, and the application materializer decides when artifacts are ready and what should be served. - -Both paths use the same client protocol and the same artifact payload shape. The difference is only where artifacts come from. +Precomputed artifacts still fit this model. They are just a special case where the materializer already knows the final outputs and uses registration only as an identity and readiness gate. ## Crate roles @@ -34,11 +30,11 @@ This crate is the application-facing integration layer. The main concepts are `R The adapter answers one question: given the current registration snapshot, are artifacts ready yet, and if so, what should be served? -The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. Static deployment-driven rendering still exists, but it lives under `cfgsync_adapter::static_deployment` as a secondary helper path. The main cfgsync model is registration-backed materialization. +The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. Static deployment-driven rendering still exists for current testing-framework consumers, but it is intentionally a secondary helper path. The main cfgsync model is registration-backed materialization. ### `cfgsync-runtime` -This crate provides operational helpers and binaries. It includes client-side fetch/write helpers, server config loading, and direct server entrypoints for materializers. Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. +This crate provides the operational entrypoints. It includes client-side fetch/write helpers, server config loading, and the default `serve_cfgsync(...)` path for snapshot materializers. Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. ## Artifact model @@ -58,13 +54,11 @@ The server stores registrations and builds a `RegistrationSnapshot`. The applica If the materializer returns `NotReady`, cfgsync responds accordingly and the client can retry later. If it returns `Ready`, cfgsync serves the resolved artifact payload. -## Static bundle flow +## Precomputed artifacts -Static bundle mode still exists because it is useful when artifacts are already known. +Some consumers know the full artifact set ahead of time. That case still fits the same registration-backed model: the server starts with precomputed `MaterializedArtifacts`, nodes register, and cfgsync serves the right payload once the registration is acceptable. -That is appropriate for fully precomputed topologies, deterministic fixtures, and test setups where no runtime coordination is needed. In that mode, cfgsync serves from `NodeArtifactsBundle` through `BundleConfigSource`. - -Bundle mode is useful, but it is not the defining idea of the library anymore. The primary model is registration-backed materialization, and the static helpers are intentionally kept off the main adapter surface. +The important point is that precomputed artifacts are not a separate public workflow anymore. They are one way to back the same registration/materialization protocol. ## Example: typed registration metadata @@ -124,14 +118,16 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { ## Example: serving cfgsync ```rust -use cfgsync_runtime::serve_snapshot_cfgsync; +use cfgsync_runtime::serve_cfgsync; # async fn run() -> anyhow::Result<()> { -serve_snapshot_cfgsync(4400, MyMaterializer).await?; +serve_cfgsync(4400, MyMaterializer).await?; # Ok(()) # } ``` +A standalone version of this example lives in `cfgsync/runtime/examples/minimal_cfgsync.rs`. + ## Example: fetching artifacts ```rust @@ -157,7 +153,7 @@ Do not push application-specific topology semantics, genesis or deployment gener ## Recommended integration path -If you are integrating a new app, the shortest sensible path is to define a typed registration payload, implement `RegistrationSnapshotMaterializer`, return node-local and optional shared artifacts, serve them with `serve_snapshot_cfgsync(...)`, and use `CfgsyncClient` or the runtime helpers on the node side. That gives you the main library value without forcing extra application logic into cfgsync itself. +If you are integrating a new app, the shortest sensible path is to define a typed registration payload, implement `RegistrationSnapshotMaterializer`, return node-local and optional shared artifacts, serve them with `serve_cfgsync(...)`, and use `CfgsyncClient` or the runtime helpers on the node side. That gives you the main library value without forcing extra application logic into cfgsync itself. ## Compatibility diff --git a/cfgsync/adapter/Cargo.toml b/cfgsync/adapter/Cargo.toml index cba0480..f8055f5 100644 --- a/cfgsync/adapter/Cargo.toml +++ b/cfgsync/adapter/Cargo.toml @@ -17,4 +17,3 @@ cfgsync-artifacts = { workspace = true } cfgsync-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } diff --git a/cfgsync/adapter/src/deployment.rs b/cfgsync/adapter/src/deployment.rs deleted file mode 100644 index c50c1de..0000000 --- a/cfgsync/adapter/src/deployment.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::{collections::HashMap, error::Error}; - -use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; -use thiserror::Error; - -use crate::MaterializedArtifacts; - -/// Adapter contract for converting an application deployment model into -/// node-specific serialized config payloads. -pub trait DeploymentAdapter { - /// Application-specific deployment model that cfgsync renders from. - type Deployment; - /// One node entry inside the deployment model. - type Node; - /// In-memory node config type produced before serialization. - type NodeConfig; - /// Adapter-specific failure type raised while building or rewriting - /// configs. - type Error: Error + Send + Sync + 'static; - - /// Returns the ordered node list that cfgsync should materialize. - fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; - - /// Returns the stable identifier cfgsync should use for this node. - fn node_identifier(index: usize, node: &Self::Node) -> String; - - /// Builds the initial in-memory config for one node before hostname - /// rewriting is applied. - fn build_node_config( - deployment: &Self::Deployment, - node: &Self::Node, - ) -> Result; - - /// Rewrites any inter-node references so the config can be served through - /// cfgsync using the provided hostnames. - fn rewrite_for_hostnames( - deployment: &Self::Deployment, - node_index: usize, - hostnames: &[String], - config: &mut Self::NodeConfig, - ) -> Result<(), Self::Error>; - - /// Serializes the final node config into the file content cfgsync should - /// deliver. - fn serialize_node_config(config: &Self::NodeConfig) -> Result; -} - -/// High-level failures while building adapter output for cfgsync. -#[derive(Debug, Error)] -pub enum BuildCfgsyncNodesError { - #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] - HostnameCountMismatch { nodes: usize, hostnames: usize }, - #[error("cfgsync adapter failed: {source}")] - Adapter { - #[source] - source: super::DynCfgsyncError, - }, -} - -fn adapter_error(source: E) -> BuildCfgsyncNodesError -where - E: Error + Send + Sync + 'static, -{ - BuildCfgsyncNodesError::Adapter { - source: Box::new(source), - } -} - -/// Builds materialized cfgsync artifacts for a deployment by: -/// 1) validating hostname count, -/// 2) building each node config, -/// 3) rewriting host references, -/// 4) serializing each node payload. -pub fn build_materialized_artifacts( - deployment: &E::Deployment, - hostnames: &[String], -) -> Result { - let nodes = E::nodes(deployment); - ensure_hostname_count(nodes.len(), hostnames.len())?; - - let mut output = HashMap::with_capacity(nodes.len()); - for (index, node) in nodes.iter().enumerate() { - let (identifier, artifacts) = build_node_entry::(deployment, node, index, hostnames)?; - output.insert(identifier, artifacts); - } - - Ok(MaterializedArtifacts::from_nodes(output)) -} - -fn ensure_hostname_count(nodes: usize, hostnames: usize) -> Result<(), BuildCfgsyncNodesError> { - if nodes != hostnames { - return Err(BuildCfgsyncNodesError::HostnameCountMismatch { nodes, hostnames }); - } - - Ok(()) -} - -fn build_node_entry( - deployment: &E::Deployment, - node: &E::Node, - index: usize, - hostnames: &[String], -) -> Result<(String, ArtifactSet), BuildCfgsyncNodesError> { - let node_config = build_rewritten_node_config::(deployment, node, index, hostnames)?; - let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; - - Ok(( - E::node_identifier(index, node), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", &config_yaml)]), - )) -} - -fn build_rewritten_node_config( - deployment: &E::Deployment, - node: &E::Node, - index: usize, - hostnames: &[String], -) -> Result { - let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; - E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) - .map_err(adapter_error)?; - - Ok(node_config) -} diff --git a/cfgsync/adapter/src/lib.rs b/cfgsync/adapter/src/lib.rs index af8f9f1..d27e9fe 100644 --- a/cfgsync/adapter/src/lib.rs +++ b/cfgsync/adapter/src/lib.rs @@ -1,5 +1,4 @@ mod artifacts; -mod deployment; mod materializer; mod registrations; mod sources; @@ -11,14 +10,3 @@ pub use materializer::{ }; pub use registrations::RegistrationSnapshot; pub use sources::RegistrationConfigSource; - -/// Static deployment helpers for precomputed cfgsync artifact generation. -/// -/// This module is intentionally secondary to the registration-backed -/// materializer flow. Use it when artifacts are already determined by a -/// deployment plan and do not need runtime registration to become available. -pub mod static_deployment { - pub use super::deployment::{ - BuildCfgsyncNodesError, DeploymentAdapter, build_materialized_artifacts, - }; -} diff --git a/cfgsync/core/src/render.rs b/cfgsync/core/src/render.rs index ec99953..51af474 100644 --- a/cfgsync/core/src/render.rs +++ b/cfgsync/core/src/render.rs @@ -64,8 +64,8 @@ pub struct CfgsyncConfigOverrides { pub n_hosts: Option, /// Minimum timeout to enforce on the rendered template. pub timeout_floor_secs: Option, - /// Override for the bundle path written into cfgsync config. - pub bundle_path: Option, + /// Override for the precomputed artifacts path written into cfgsync config. + pub artifacts_path: Option, /// Optional OTLP metrics endpoint injected into tracing settings. pub metrics_otlp_ingest_url: Option, } @@ -113,10 +113,10 @@ pub fn apply_cfgsync_overrides( ); } - if let Some(bundle_path) = &overrides.bundle_path { + if let Some(artifacts_path) = &overrides.artifacts_path { root.insert( - Value::String("bundle_path".to_string()), - Value::String(bundle_path.clone()), + Value::String("artifacts_path".to_string()), + Value::String(artifacts_path.clone()), ); } diff --git a/cfgsync/runtime/Cargo.toml b/cfgsync/runtime/Cargo.toml index 547b182..a05d244 100644 --- a/cfgsync/runtime/Cargo.toml +++ b/cfgsync/runtime/Cargo.toml @@ -13,16 +13,17 @@ version = { workspace = true } workspace = true [dependencies] -anyhow = "1" -axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } -cfgsync-adapter = { workspace = true } -cfgsync-core = { workspace = true } -clap = { version = "4", features = ["derive"] } -serde = { workspace = true } -serde_yaml = { workspace = true } -thiserror = { workspace = true } -tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } -tracing = { workspace = true } +anyhow = "1" +axum = { default-features = false, features = ["http1", "http2", "tokio"], version = "0.7.5" } +cfgsync-adapter = { workspace = true } +cfgsync-artifacts = { workspace = true } +cfgsync-core = { workspace = true } +clap = { version = "4", features = ["derive"] } +serde = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tokio = { default-features = false, features = ["macros", "net", "rt-multi-thread"], version = "1" } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs new file mode 100644 index 0000000..e000a7e --- /dev/null +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -0,0 +1,39 @@ +use cfgsync_adapter::{ + DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot, + RegistrationSnapshotMaterializer, +}; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; +use cfgsync_runtime::serve_cfgsync; + +struct ExampleMaterializer; + +impl RegistrationSnapshotMaterializer for ExampleMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result { + if registrations.is_empty() { + return Ok(MaterializationResult::NotReady); + } + + let nodes = registrations.iter().map(|registration| { + ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml", + format!("id: {}\n", registration.identifier), + )]), + ) + }); + + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_nodes(nodes), + )) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + serve_cfgsync(4400, ExampleMaterializer).await?; + Ok(()) +} diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index d51807c..fcca208 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -8,7 +8,7 @@ pub use client::{ run_cfgsync_client_from_env, }; pub use server::{ - CfgsyncServerConfig, CfgsyncServerSource, LoadCfgsyncServerConfigError, - build_persisted_snapshot_cfgsync_router, build_snapshot_cfgsync_router, - serve_cfgsync_from_config, serve_persisted_snapshot_cfgsync, serve_snapshot_cfgsync, + CfgsyncServerConfig, CfgsyncServerSource, LoadCfgsyncServerConfigError, build_cfgsync_router, + build_persisted_cfgsync_router, serve_cfgsync, serve_cfgsync_from_config, + serve_persisted_cfgsync, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 72a44ea..242eeb6 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -8,13 +8,13 @@ use cfgsync_adapter::{ }; use cfgsync_core::{ BundleConfigSource, CfgsyncServerState, NodeConfigSource, RunCfgsyncError, - build_cfgsync_router, serve_cfgsync, + serve_cfgsync as serve_cfgsync_state, }; -use serde::{Deserialize, de::Error as _}; +use serde::Deserialize; use thiserror::Error; /// Runtime cfgsync server config loaded from YAML. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct CfgsyncServerConfig { /// HTTP port to bind the cfgsync server on. pub port: u16, @@ -35,26 +35,7 @@ pub enum CfgsyncServerSource { /// Serve a static precomputed artifact bundle directly. Bundle { bundle_path: String }, /// Require node registration before serving precomputed artifacts. - #[serde(alias = "registration_bundle")] - Registration { - #[serde(alias = "bundle_path")] - artifacts_path: String, - }, -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum LegacyServingMode { - Bundle, - Registration, -} - -#[derive(Debug, Deserialize)] -struct RawCfgsyncServerConfig { - port: u16, - source: Option, - bundle_path: Option, - serving_mode: Option, + Registration { artifacts_path: String }, } #[derive(Debug, Error)] @@ -83,7 +64,7 @@ impl CfgsyncServerConfig { source, })?; - let raw: RawCfgsyncServerConfig = + let config: CfgsyncServerConfig = serde_yaml::from_str(&config_content).map_err(|source| { LoadCfgsyncServerConfigError::Parse { path: config_path, @@ -91,10 +72,7 @@ impl CfgsyncServerConfig { } })?; - Self::from_raw(raw).map_err(|source| LoadCfgsyncServerConfigError::Parse { - path: path.display().to_string(), - source, - }) + Ok(config) } #[must_use] @@ -118,30 +96,6 @@ impl CfgsyncServerConfig { }, } } - - fn from_raw(raw: RawCfgsyncServerConfig) -> Result { - let source = match (raw.source, raw.bundle_path, raw.serving_mode) { - (Some(source), _, _) => source, - (None, Some(bundle_path), Some(LegacyServingMode::Registration)) => { - CfgsyncServerSource::Registration { - artifacts_path: bundle_path, - } - } - (None, Some(bundle_path), None | Some(LegacyServingMode::Bundle)) => { - CfgsyncServerSource::Bundle { bundle_path } - } - (None, None, _) => { - return Err(serde_yaml::Error::custom( - "cfgsync server config requires source.kind or legacy bundle_path", - )); - } - }; - - Ok(Self { - port: raw.port, - source, - }) - } } fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { @@ -194,12 +148,12 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> let bundle_path = resolve_source_path(config_path, &config.source); let state = build_server_state(&config, &bundle_path)?; - serve_cfgsync(config.port, state).await?; + serve_cfgsync_state(config.port, state).await?; Ok(()) } -/// Builds a registration-backed cfgsync router directly from a snapshot +/// Builds the default registration-backed cfgsync router from a snapshot /// materializer. /// /// This is the main code-driven entrypoint for apps that want cfgsync to own: @@ -208,12 +162,12 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> /// - artifact serving /// /// while the app owns only snapshot materialization logic. -pub fn build_snapshot_cfgsync_router(materializer: M) -> Router +pub fn build_cfgsync_router(materializer: M) -> Router where M: RegistrationSnapshotMaterializer + 'static, { let provider = RegistrationConfigSource::new(CachedSnapshotMaterializer::new(materializer)); - build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) + cfgsync_core::build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) } /// Builds a registration-backed cfgsync router with a persistence hook for @@ -221,7 +175,7 @@ where /// /// Use this when the application wants cfgsync to persist or publish shared /// artifacts after a snapshot becomes ready. -pub fn build_persisted_snapshot_cfgsync_router(materializer: M, sink: S) -> Router +pub fn build_persisted_cfgsync_router(materializer: M, sink: S) -> Router where M: RegistrationSnapshotMaterializer + 'static, S: MaterializedArtifactsSink + 'static, @@ -230,19 +184,19 @@ where PersistingSnapshotMaterializer::new(materializer, sink), )); - build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) + cfgsync_core::build_cfgsync_router(CfgsyncServerState::new(Arc::new(provider))) } -/// Runs a registration-backed cfgsync server directly from a snapshot +/// Runs the default registration-backed cfgsync server directly from a snapshot /// materializer. /// /// This is the simplest runtime entrypoint when the application already has a /// materializer value and does not need to compose extra routes. -pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> +pub async fn serve_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> where M: RegistrationSnapshotMaterializer + 'static, { - let router = build_snapshot_cfgsync_router(materializer); + let router = build_cfgsync_router(materializer); serve_router(port, router).await } @@ -250,7 +204,50 @@ where /// materialization results. /// /// This is the direct serving counterpart to -/// [`build_persisted_snapshot_cfgsync_router`]. +/// [`build_persisted_cfgsync_router`]. +pub async fn serve_persisted_cfgsync( + port: u16, + materializer: M, + sink: S, +) -> Result<(), RunCfgsyncError> +where + M: RegistrationSnapshotMaterializer + 'static, + S: MaterializedArtifactsSink + 'static, +{ + let router = build_persisted_cfgsync_router(materializer, sink); + serve_router(port, router).await +} + +#[doc(hidden)] +#[allow(dead_code)] +pub fn build_snapshot_cfgsync_router(materializer: M) -> Router +where + M: RegistrationSnapshotMaterializer + 'static, +{ + build_cfgsync_router(materializer) +} + +#[doc(hidden)] +#[allow(dead_code)] +pub fn build_persisted_snapshot_cfgsync_router(materializer: M, sink: S) -> Router +where + M: RegistrationSnapshotMaterializer + 'static, + S: MaterializedArtifactsSink + 'static, +{ + build_persisted_cfgsync_router(materializer, sink) +} + +#[doc(hidden)] +#[allow(dead_code)] +pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> +where + M: RegistrationSnapshotMaterializer + 'static, +{ + serve_cfgsync(port, materializer).await +} + +#[doc(hidden)] +#[allow(dead_code)] pub async fn serve_persisted_snapshot_cfgsync( port: u16, materializer: M, @@ -260,8 +257,7 @@ where M: RegistrationSnapshotMaterializer + 'static, S: MaterializedArtifactsSink + 'static, { - let router = build_persisted_snapshot_cfgsync_router(materializer, sink); - serve_router(port, router).await + serve_persisted_cfgsync(port, materializer, sink).await } async fn serve_router(port: u16, router: Router) -> Result<(), RunCfgsyncError> { diff --git a/logos/infra/helm/logos-runner/templates/cfgsync-deployment.yaml b/logos/infra/helm/logos-runner/templates/cfgsync-deployment.yaml index 7362ab3..f76dc99 100644 --- a/logos/infra/helm/logos-runner/templates/cfgsync-deployment.yaml +++ b/logos/infra/helm/logos-runner/templates/cfgsync-deployment.yaml @@ -39,7 +39,7 @@ spec: items: - key: cfgsync.yaml path: cfgsync.yaml - - key: cfgsync.bundle.yaml - path: cfgsync.bundle.yaml + - key: cfgsync.artifacts.yaml + path: cfgsync.artifacts.yaml - key: run_cfgsync.sh path: scripts/run_cfgsync.sh diff --git a/logos/infra/helm/logos-runner/templates/configmap.yaml b/logos/infra/helm/logos-runner/templates/configmap.yaml index a962e6a..36cd339 100644 --- a/logos/infra/helm/logos-runner/templates/configmap.yaml +++ b/logos/infra/helm/logos-runner/templates/configmap.yaml @@ -11,9 +11,9 @@ data: {{- else }} {{ "" | indent 4 }} {{- end }} - cfgsync.bundle.yaml: | -{{- if .Values.cfgsync.bundle }} -{{ .Values.cfgsync.bundle | indent 4 }} + cfgsync.artifacts.yaml: | +{{- if .Values.cfgsync.artifacts }} +{{ .Values.cfgsync.artifacts | indent 4 }} {{- else }} {{ "" | indent 4 }} {{- end }} diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 7b86f67..5f05fb7 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use cfgsync_adapter::static_deployment::{DeploymentAdapter, build_materialized_artifacts}; use cfgsync_artifacts::ArtifactFile; pub(crate) use cfgsync_core::render::CfgsyncOutputPaths; use cfgsync_core::render::{ @@ -8,6 +7,7 @@ use cfgsync_core::render::{ }; use reqwest::Url; use serde_yaml::{Mapping, Value}; +use testing_framework_core::cfgsync::{StaticArtifactRenderer, build_static_artifacts}; use thiserror::Error; pub(crate) struct CfgsyncRenderOptions { @@ -25,7 +25,7 @@ enum BundleRenderError { MissingYamlKey { key: String }, } -pub(crate) fn render_cfgsync_from_template( +pub(crate) fn render_cfgsync_from_template( topology: &E::Deployment, hostnames: &[String], options: CfgsyncRenderOptions, @@ -33,7 +33,7 @@ pub(crate) fn render_cfgsync_from_template( let cfg = build_cfgsync_server_config(); let overrides = build_overrides::(topology, options); let config_yaml = render_cfgsync_yaml_from_template(cfg, &overrides)?; - let mut materialized = build_materialized_artifacts::(topology, hostnames)?; + let mut materialized = build_static_artifacts::(topology, hostnames)?; append_deployment_files(&mut materialized)?; let artifacts_yaml = serde_yaml::to_string(&materialized)?; @@ -122,7 +122,7 @@ fn build_cfgsync_server_config() -> Value { Value::Mapping(root) } -pub(crate) fn render_and_write_cfgsync_from_template( +pub(crate) fn render_and_write_cfgsync_from_template( topology: &E::Deployment, hostnames: &[String], mut options: CfgsyncRenderOptions, @@ -136,7 +136,7 @@ pub(crate) fn render_and_write_cfgsync_from_template( Ok(rendered) } -fn build_overrides( +fn build_overrides( topology: &E::Deployment, options: CfgsyncRenderOptions, ) -> CfgsyncConfigOverrides { @@ -151,7 +151,7 @@ fn build_overrides( port, n_hosts: Some(E::nodes(topology).len()), timeout_floor_secs: min_timeout_secs, - bundle_path: artifacts_path, + artifacts_path, metrics_otlp_ingest_url: metrics_otlp_ingest_url.map(|url| url.to_string()), } } diff --git a/logos/runtime/ext/src/compose_env.rs b/logos/runtime/ext/src/compose_env.rs index b1bb1eb..f539ce3 100644 --- a/logos/runtime/ext/src/compose_env.rs +++ b/logos/runtime/ext/src/compose_env.rs @@ -117,7 +117,7 @@ impl ComposeDeployEnv for LbcExtEnv { nodes = topology.nodes().len(), "updating cfgsync template" ); - let bundle_path = cfgsync_bundle_path(path); + let artifacts_path = cfgsync_artifacts_path(path); let hostnames = topology_hostnames(topology); let options = cfgsync_render_options(port, metrics_otlp_ingest_url); @@ -127,7 +127,7 @@ impl ComposeDeployEnv for LbcExtEnv { options, CfgsyncOutputPaths { config_path: path, - artifacts_path: &bundle_path, + artifacts_path: &artifacts_path, }, )?; Ok(()) @@ -186,7 +186,7 @@ fn node_instance_name(index: usize) -> String { format!("node-{index}") } -fn cfgsync_bundle_path(config_path: &Path) -> PathBuf { +fn cfgsync_artifacts_path(config_path: &Path) -> PathBuf { config_path .parent() .unwrap_or(config_path) diff --git a/logos/runtime/ext/src/k8s_env.rs b/logos/runtime/ext/src/k8s_env.rs index b88a9a0..ee90f2c 100644 --- a/logos/runtime/ext/src/k8s_env.rs +++ b/logos/runtime/ext/src/k8s_env.rs @@ -182,11 +182,11 @@ pub fn prepare_assets( let root = workspace_root().map_err(|source| AssetsError::WorkspaceRoot { source })?; let tempdir = create_assets_tempdir()?; - let (cfgsync_file, cfgsync_yaml, bundle_yaml) = + let (cfgsync_file, cfgsync_yaml, artifacts_yaml) = render_and_write_cfgsync(topology, metrics_otlp_ingest_url, &tempdir)?; let scripts = validate_scripts(&root)?; let chart_path = helm_chart_path()?; - let values_file = render_and_write_values(topology, &tempdir, &cfgsync_yaml, &bundle_yaml)?; + let values_file = render_and_write_values(topology, &tempdir, &cfgsync_yaml, &artifacts_yaml)?; let image = testnet_image(); log_assets_prepare_done(&cfgsync_file, &values_file, &chart_path, &image); @@ -569,7 +569,7 @@ struct KzgValues { struct CfgsyncValues { port: u16, config: String, - bundle: String, + artifacts: String, } #[derive(Serialize)] @@ -589,11 +589,11 @@ struct NodeValues { env: BTreeMap, } -fn build_values(topology: &DeploymentPlan, cfgsync_yaml: &str, bundle_yaml: &str) -> HelmValues { +fn build_values(topology: &DeploymentPlan, cfgsync_yaml: &str, artifacts_yaml: &str) -> HelmValues { let cfgsync = CfgsyncValues { port: cfgsync_port(), config: cfgsync_yaml.to_string(), - bundle: bundle_yaml.to_string(), + artifacts: artifacts_yaml.to_string(), }; let kzg = KzgValues::disabled(); let image_pull_policy = diff --git a/testing-framework/core/Cargo.toml b/testing-framework/core/Cargo.toml index cda1d37..492f746 100644 --- a/testing-framework/core/Cargo.toml +++ b/testing-framework/core/Cargo.toml @@ -18,6 +18,7 @@ default = [] [dependencies] async-trait = "0.1" cfgsync-adapter = { workspace = true } +cfgsync-artifacts = { workspace = true } futures = { default-features = false, features = ["std"], version = "0.3" } parking_lot = { workspace = true } prometheus-http-query = "0.8" diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index 207265a..e48642c 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -1,5 +1,90 @@ -#[doc(hidden)] -pub use cfgsync_adapter::static_deployment::{ - DeploymentAdapter as CfgsyncEnv, build_materialized_artifacts as build_cfgsync_node_catalog, -}; +use std::error::Error; + pub use cfgsync_adapter::*; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; +use thiserror::Error; + +#[doc(hidden)] +pub type DynCfgsyncError = Box; + +#[doc(hidden)] +pub trait StaticArtifactRenderer { + type Deployment; + type Node; + type NodeConfig; + type Error: Error + Send + Sync + 'static; + + fn nodes(deployment: &Self::Deployment) -> &[Self::Node]; + + fn node_identifier(index: usize, node: &Self::Node) -> String; + + fn build_node_config( + deployment: &Self::Deployment, + node: &Self::Node, + ) -> Result; + + fn rewrite_for_hostnames( + deployment: &Self::Deployment, + node_index: usize, + hostnames: &[String], + config: &mut Self::NodeConfig, + ) -> Result<(), Self::Error>; + + fn serialize_node_config(config: &Self::NodeConfig) -> Result; +} + +#[doc(hidden)] +pub use StaticArtifactRenderer as CfgsyncEnv; + +#[derive(Debug, Error)] +pub enum BuildStaticArtifactsError { + #[error("cfgsync hostnames mismatch (nodes={nodes}, hostnames={hostnames})")] + HostnameCountMismatch { nodes: usize, hostnames: usize }, + #[error("cfgsync adapter failed: {source}")] + Adapter { + #[source] + source: DynCfgsyncError, + }, +} + +fn adapter_error(source: E) -> BuildStaticArtifactsError +where + E: Error + Send + Sync + 'static, +{ + BuildStaticArtifactsError::Adapter { + source: Box::new(source), + } +} + +pub fn build_static_artifacts( + deployment: &E::Deployment, + hostnames: &[String], +) -> Result { + let nodes = E::nodes(deployment); + + if nodes.len() != hostnames.len() { + return Err(BuildStaticArtifactsError::HostnameCountMismatch { + nodes: nodes.len(), + hostnames: hostnames.len(), + }); + } + + let mut output = std::collections::HashMap::with_capacity(nodes.len()); + + for (index, node) in nodes.iter().enumerate() { + let mut node_config = E::build_node_config(deployment, node).map_err(adapter_error)?; + E::rewrite_for_hostnames(deployment, index, hostnames, &mut node_config) + .map_err(adapter_error)?; + let config_yaml = E::serialize_node_config(&node_config).map_err(adapter_error)?; + + output.insert( + E::node_identifier(index, node), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", &config_yaml)]), + ); + } + + Ok(cfgsync_adapter::MaterializedArtifacts::from_nodes(output)) +} + +#[doc(hidden)] +pub use build_static_artifacts as build_cfgsync_node_catalog; diff --git a/testing-framework/core/src/lib.rs b/testing-framework/core/src/lib.rs index 3e76f81..5cbdb97 100644 --- a/testing-framework/core/src/lib.rs +++ b/testing-framework/core/src/lib.rs @@ -1,7 +1,3 @@ -#[deprecated( - since = "0.1.0", - note = "testing-framework-core::cfgsync moved to cfgsync-adapter; update imports" -)] pub mod cfgsync; pub mod env; pub mod runtime; From faa5814373b016af453662d4f48c0fda6b009699 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:41:04 +0100 Subject: [PATCH 30/38] Remove dead cfgsync compatibility shims --- cfgsync/runtime/src/server.rs | 42 ----------------------------------- 1 file changed, 42 deletions(-) diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 242eeb6..27e9a27 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -218,48 +218,6 @@ where serve_router(port, router).await } -#[doc(hidden)] -#[allow(dead_code)] -pub fn build_snapshot_cfgsync_router(materializer: M) -> Router -where - M: RegistrationSnapshotMaterializer + 'static, -{ - build_cfgsync_router(materializer) -} - -#[doc(hidden)] -#[allow(dead_code)] -pub fn build_persisted_snapshot_cfgsync_router(materializer: M, sink: S) -> Router -where - M: RegistrationSnapshotMaterializer + 'static, - S: MaterializedArtifactsSink + 'static, -{ - build_persisted_cfgsync_router(materializer, sink) -} - -#[doc(hidden)] -#[allow(dead_code)] -pub async fn serve_snapshot_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> -where - M: RegistrationSnapshotMaterializer + 'static, -{ - serve_cfgsync(port, materializer).await -} - -#[doc(hidden)] -#[allow(dead_code)] -pub async fn serve_persisted_snapshot_cfgsync( - port: u16, - materializer: M, - sink: S, -) -> Result<(), RunCfgsyncError> -where - M: RegistrationSnapshotMaterializer + 'static, - S: MaterializedArtifactsSink + 'static, -{ - serve_persisted_cfgsync(port, materializer, sink).await -} - async fn serve_router(port: u16, router: Router) -> Result<(), RunCfgsyncError> { let bind_addr = format!("0.0.0.0:{port}"); let listener = tokio::net::TcpListener::bind(&bind_addr) From 58dff8f71881e0138e97e0d86471625aed7d89fd Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:42:38 +0100 Subject: [PATCH 31/38] Rewrite cfgsync README around the actual model --- cfgsync/README.md | 219 +++++++++++++++++++++++++++++++++------------- 1 file changed, 157 insertions(+), 62 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index c964712..759cc0f 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -1,66 +1,175 @@ # cfgsync -`cfgsync` is a small library stack for node registration and config artifact delivery. +`cfgsync` is a small library stack for bootstrap-time config delivery. -It is meant for distributed bootstrap flows where nodes register themselves with a config service, wait until artifacts are ready, fetch one payload containing the files they need, and then write those files locally before continuing startup. +The library solves one problem: nodes need to identify themselves, wait until configuration is ready, fetch the files they need, write them locally, and then continue startup. `cfgsync` owns that transport and serving loop. The application using it still decides what “ready” means and what files should be generated. -The boundary is simple. `cfgsync` owns transport, registration storage, polling, and artifact serving. The application adapter owns readiness policy and artifact generation. That keeps the library reusable without forcing application-specific bootstrap logic into core crates. +That split is the point of the design: -## The model +- `cfgsync` owns registration, polling, payload transport, and file delivery. +- the application adapter owns readiness policy and artifact generation. -There is one main way to use `cfgsync`: nodes register, the server evaluates the current registration snapshot, and the application materializer decides whether artifacts are ready yet. Once ready, cfgsync serves a single payload containing both node-local and shared files. +The result is a reusable library without application-specific bootstrap logic leaking into core crates. -Precomputed artifacts still fit this model. They are just a special case where the materializer already knows the final outputs and uses registration only as an identity and readiness gate. +## How it works -## Crate roles +The normal flow is registration-backed serving. -### `cfgsync-artifacts` +Each node first sends a registration containing: -This crate defines the file-level data model. `ArtifactFile` represents one file and `ArtifactSet` represents a group of files delivered together. If you only need to talk about files and file groups, this is the crate you use. +- a stable node identifier +- its IP address +- optional typed application metadata -### `cfgsync-core` +The server stores registrations and builds a `RegistrationSnapshot`. The application provides a `RegistrationSnapshotMaterializer`, which receives that snapshot and decides whether configuration is ready yet. -This crate defines the protocol and the low-level server/client pieces. The central types are `NodeRegistration`, `RegistrationPayload`, `NodeArtifactsPayload`, `CfgsyncClient`, and the `NodeConfigSource` implementations used by the server. +If the materializer returns `NotReady`, the node keeps polling. If it returns `Ready`, cfgsync serves one payload containing: -It also defines the generic HTTP contract: nodes `POST /register`, then `POST /node` to fetch artifacts. The server responds with either a payload, `NotReady`, or `Missing`. +- node-local files for the requesting node +- optional shared files that every node should receive -### `cfgsync-adapter` +The node then writes those files locally and continues startup. -This crate is the application-facing integration layer. The main concepts are `RegistrationSnapshot`, `RegistrationSnapshotMaterializer`, `MaterializedArtifacts`, and `MaterializationResult`. - -The adapter answers one question: given the current registration snapshot, are artifacts ready yet, and if so, what should be served? - -The crate also includes reusable wrappers such as `CachedSnapshotMaterializer`, `PersistingSnapshotMaterializer`, and `RegistrationConfigSource`. Static deployment-driven rendering still exists for current testing-framework consumers, but it is intentionally a secondary helper path. The main cfgsync model is registration-backed materialization. - -### `cfgsync-runtime` - -This crate provides the operational entrypoints. It includes client-side fetch/write helpers, server config loading, and the default `serve_cfgsync(...)` path for snapshot materializers. Use this crate when you want to run cfgsync rather than define its protocol or adapter contracts. - -## Artifact model - -`cfgsync` serves one node request at a time, but the adapter usually thinks in snapshots. - -The adapter produces `MaterializedArtifacts`, which contain node-local artifacts keyed by node identifier plus optional shared artifacts delivered alongside every node. When one node requests config, cfgsync resolves that node’s local files, merges in the shared files, and returns a single payload. - -This is why applications do not need separate “node config” and “shared config” endpoints unless they want legacy compatibility. - -## Registration-backed flow - -This is the main integration path. - -The node sends a `NodeRegistration` containing a stable identifier, an IP address, and optional typed application metadata. That metadata is opaque to cfgsync itself and is only interpreted by the application adapter. - -The server stores registrations and builds a `RegistrationSnapshot`. The application implements `RegistrationSnapshotMaterializer` and decides whether the current snapshot is ready, which node-local artifacts should be produced, and which shared artifacts should accompany them. - -If the materializer returns `NotReady`, cfgsync responds accordingly and the client can retry later. If it returns `Ready`, cfgsync serves the resolved artifact payload. +That is the main model. Everything else is a variation of it. ## Precomputed artifacts -Some consumers know the full artifact set ahead of time. That case still fits the same registration-backed model: the server starts with precomputed `MaterializedArtifacts`, nodes register, and cfgsync serves the right payload once the registration is acceptable. +Some systems already know the final artifacts before any node starts. That still fits the same model. -The important point is that precomputed artifacts are not a separate public workflow anymore. They are one way to back the same registration/materialization protocol. +In that case the server simply starts with precomputed `MaterializedArtifacts`. Nodes still register and fetch through the same protocol, but the materializer already knows the final outputs. Registration becomes an identity and readiness gate, not a source of topology discovery. -## Example: typed registration metadata +This is why cfgsync no longer needs a separate “static mode” as a first-class concept. Precomputed serving is just registration-backed serving with an already-known result. + +## Crate layout + +### `cfgsync-artifacts` + +This crate contains the file-level data model: + +- `ArtifactFile` for a single file +- `ArtifactSet` for a group of files + +If all you need is “what files exist and how are they grouped”, this is the crate to look at. + +### `cfgsync-core` + +This crate contains the protocol and the low-level HTTP implementation. + +Important types here are: + +- `NodeRegistration` +- `RegistrationPayload` +- `NodeArtifactsPayload` +- `CfgsyncClient` +- `NodeConfigSource` + +It also defines the HTTP contract: + +- `POST /register` +- `POST /node` + +The server answers with either a payload, `NotReady`, or `Missing`. + +### `cfgsync-adapter` + +This crate defines the application-facing seam. + +The key types are: + +- `RegistrationSnapshot` +- `RegistrationSnapshotMaterializer` +- `MaterializedArtifacts` +- `MaterializationResult` + +The adapter’s job is simple: given the current registration snapshot, decide whether artifacts are ready, and if they are, return them. + +The crate also contains reusable wrappers around that seam: + +- `CachedSnapshotMaterializer` +- `PersistingSnapshotMaterializer` +- `RegistrationConfigSource` + +These exist because caching and result persistence are generic orchestration concerns, not application-specific logic. + +### `cfgsync-runtime` + +This crate provides the operational entrypoints. + +Use it when you want to run cfgsync rather than define its protocol: + +- client-side fetch/write helpers +- server config loading +- direct serving helpers such as `serve_cfgsync(...)` + +This is the crate that should feel like the normal “start here” path for users integrating cfgsync into a real system. + +## Artifact model + +The adapter usually thinks in full snapshots, but cfgsync serves one node at a time. + +The materializer returns `MaterializedArtifacts`, which contain: + +- node-local artifacts keyed by node identifier +- optional shared artifacts + +When one node fetches config, cfgsync resolves that node’s local files, merges in the shared files, and returns a single payload. + +That is why applications usually do not need a second “shared config” endpoint. Shared files can travel in the same payload as node-local files. + +## The adapter boundary + +The adapter is where application semantics belong. + +In practice, the adapter should define: + +- the typed registration payload +- the readiness rule +- the conversion from registration snapshots into artifacts +- any shared artifact generation the application needs + +Typical examples are: + +- waiting for `n` initial nodes +- deriving peer lists from registrations +- generating one node-local config file per node +- generating one shared deployment file for all nodes + +What does not belong in cfgsync core is equally important. Generic cfgsync should not understand: + +- application-specific topology semantics +- genesis or deployment generation rules for one protocol +- application-specific command/state-machine logic +- domain-specific ideas of what a node “really is” + +Those belong in the adapter or in the consuming application. + +## Minimal integration path + +For a new application, the shortest sensible path is: + +1. define a typed registration payload +2. implement `RegistrationSnapshotMaterializer` +3. return node-local and optional shared artifacts +4. serve them with `serve_cfgsync(...)` +5. use `CfgsyncClient` or the runtime helpers on the node side + +That gives you the main value of the library without forcing extra application logic into cfgsync itself. + +## Minimal example + +A minimal standalone example lives in: + +- `cfgsync/runtime/examples/minimal_cfgsync.rs` + +It shows the smallest complete path: + +- registration snapshot materializer +- cfgsync server +- node artifact generation + +## Code sketch + +Typed registration payload: ```rust use cfgsync_core::NodeRegistration; @@ -78,7 +187,7 @@ let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().unwrap()) })?; ``` -## Example: snapshot materializer +Snapshot materializer: ```rust use cfgsync_adapter::{ @@ -115,7 +224,7 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { } ``` -## Example: serving cfgsync +Serving: ```rust use cfgsync_runtime::serve_cfgsync; @@ -126,9 +235,7 @@ serve_cfgsync(4400, MyMaterializer).await?; # } ``` -A standalone version of this example lives in `cfgsync/runtime/examples/minimal_cfgsync.rs`. - -## Example: fetching artifacts +Fetching and writing artifacts: ```rust use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts}; @@ -143,20 +250,8 @@ fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).awai # } ``` -## What belongs in the adapter - -The adapter should own the application-specific parts of bootstrap: the registration payload type, the readiness rule, the conversion from registration snapshots into artifacts, and any shared artifact generation your app needs. In practice that means things like waiting for `n` initial nodes, deriving peer lists from registrations, building node-local config files, or generating one shared deployment file for all nodes. - -## What does not belong in cfgsync core - -Do not push application-specific topology semantics, genesis or deployment generation, command/state-machine logic, or domain-specific ideas of what a node means into generic cfgsync. Those belong in the adapter or the consuming application. - -## Recommended integration path - -If you are integrating a new app, the shortest sensible path is to define a typed registration payload, implement `RegistrationSnapshotMaterializer`, return node-local and optional shared artifacts, serve them with `serve_cfgsync(...)`, and use `CfgsyncClient` or the runtime helpers on the node side. That gives you the main library value without forcing extra application logic into cfgsync itself. - ## Compatibility -The primary supported surface is what is reexported from the crate roots. +The intended public API is what the crate roots reexport today. -Some older names and compatibility paths still exist internally, but they are not the intended public API. +Some older compatibility paths still exist internally to avoid breaking current in-repo consumers, but they are not the main model and should not be treated as the recommended public surface. From 566a69af4cf6136401dd310e48927d7249f62f5c Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:45:13 +0100 Subject: [PATCH 32/38] Make cfgsync example end-to-end --- cfgsync/runtime/examples/minimal_cfgsync.rs | 22 +++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs index e000a7e..2235705 100644 --- a/cfgsync/runtime/examples/minimal_cfgsync.rs +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -3,7 +3,10 @@ use cfgsync_adapter::{ RegistrationSnapshotMaterializer, }; use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; -use cfgsync_runtime::serve_cfgsync; +use cfgsync_core::NodeRegistration; +use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts, serve_cfgsync}; +use tempfile::tempdir; +use tokio::time::{Duration, sleep}; struct ExampleMaterializer; @@ -34,6 +37,21 @@ impl RegistrationSnapshotMaterializer for ExampleMaterializer { #[tokio::main] async fn main() -> anyhow::Result<()> { - serve_cfgsync(4400, ExampleMaterializer).await?; + let port = 4400; + let server = tokio::spawn(async move { serve_cfgsync(port, ExampleMaterializer).await }); + + // Give the server a moment to bind before the client registers. + sleep(Duration::from_millis(100)).await; + + let tempdir = tempdir()?; + let config_path = tempdir.path().join("config.yaml"); + let outputs = ArtifactOutputMap::new().route("/config.yaml", &config_path); + let registration = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + + fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).await?; + + println!("{}", std::fs::read_to_string(&config_path)?); + + server.abort(); Ok(()) } From ff658e322d519bb6ecf22c68dacb59f6f83afe32 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:51:03 +0100 Subject: [PATCH 33/38] Simplify cfgsync runtime naming --- cfgsync/README.md | 14 +- cfgsync/runtime/examples/minimal_cfgsync.rs | 8 +- cfgsync/runtime/src/bin/cfgsync-client.rs | 4 +- cfgsync/runtime/src/bin/cfgsync-server.rs | 4 +- cfgsync/runtime/src/client.rs | 218 ++++++++++++-------- cfgsync/runtime/src/lib.rs | 10 +- cfgsync/runtime/src/server.rs | 63 +++--- 7 files changed, 177 insertions(+), 144 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 759cc0f..c113019 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -99,7 +99,7 @@ Use it when you want to run cfgsync rather than define its protocol: - client-side fetch/write helpers - server config loading -- direct serving helpers such as `serve_cfgsync(...)` +- direct serving helpers such as `serve(...)` This is the crate that should feel like the normal “start here” path for users integrating cfgsync into a real system. @@ -150,7 +150,7 @@ For a new application, the shortest sensible path is: 1. define a typed registration payload 2. implement `RegistrationSnapshotMaterializer` 3. return node-local and optional shared artifacts -4. serve them with `serve_cfgsync(...)` +4. serve them with `serve(...)` 5. use `CfgsyncClient` or the runtime helpers on the node side That gives you the main value of the library without forcing extra application logic into cfgsync itself. @@ -227,10 +227,10 @@ impl RegistrationSnapshotMaterializer for MyMaterializer { Serving: ```rust -use cfgsync_runtime::serve_cfgsync; +use cfgsync_runtime::serve; # async fn run() -> anyhow::Result<()> { -serve_cfgsync(4400, MyMaterializer).await?; +serve(4400, MyMaterializer).await?; # Ok(()) # } ``` @@ -238,14 +238,14 @@ serve_cfgsync(4400, MyMaterializer).await?; Fetching and writing artifacts: ```rust -use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts}; +use cfgsync_runtime::{OutputMap, fetch_and_write}; # async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { -let outputs = ArtifactOutputMap::new() +let outputs = OutputMap::new() .route("/config.yaml", "/node-data/node-1/config.yaml") .route("deployment-settings.yaml", "/node-data/shared/deployment-settings.yaml"); -fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).await?; +fetch_and_write(®istration, "http://127.0.0.1:4400", &outputs).await?; # Ok(()) # } ``` diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs index 2235705..b2aabda 100644 --- a/cfgsync/runtime/examples/minimal_cfgsync.rs +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -4,7 +4,7 @@ use cfgsync_adapter::{ }; use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; use cfgsync_core::NodeRegistration; -use cfgsync_runtime::{ArtifactOutputMap, fetch_and_write_artifacts, serve_cfgsync}; +use cfgsync_runtime::{OutputMap, fetch_and_write, serve}; use tempfile::tempdir; use tokio::time::{Duration, sleep}; @@ -38,17 +38,17 @@ impl RegistrationSnapshotMaterializer for ExampleMaterializer { #[tokio::main] async fn main() -> anyhow::Result<()> { let port = 4400; - let server = tokio::spawn(async move { serve_cfgsync(port, ExampleMaterializer).await }); + let server = tokio::spawn(async move { serve(port, ExampleMaterializer).await }); // Give the server a moment to bind before the client registers. sleep(Duration::from_millis(100)).await; let tempdir = tempdir()?; let config_path = tempdir.path().join("config.yaml"); - let outputs = ArtifactOutputMap::new().route("/config.yaml", &config_path); + let outputs = OutputMap::new().route("/config.yaml", &config_path); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse()?); - fetch_and_write_artifacts(®istration, "http://127.0.0.1:4400", &outputs).await?; + fetch_and_write(®istration, "http://127.0.0.1:4400", &outputs).await?; println!("{}", std::fs::read_to_string(&config_path)?); diff --git a/cfgsync/runtime/src/bin/cfgsync-client.rs b/cfgsync/runtime/src/bin/cfgsync-client.rs index 98c3914..b821679 100644 --- a/cfgsync/runtime/src/bin/cfgsync-client.rs +++ b/cfgsync/runtime/src/bin/cfgsync-client.rs @@ -1,6 +1,6 @@ use std::{env, process}; -use cfgsync_runtime::run_cfgsync_client_from_env; +use cfgsync_runtime::run_client_from_env; const CFGSYNC_PORT_ENV: &str = "LOGOS_BLOCKCHAIN_CFGSYNC_PORT"; const DEFAULT_CFGSYNC_PORT: u16 = 4400; @@ -14,7 +14,7 @@ fn cfgsync_port() -> u16 { #[tokio::main] async fn main() { - if let Err(err) = run_cfgsync_client_from_env(cfgsync_port()).await { + if let Err(err) = run_client_from_env(cfgsync_port()).await { eprintln!("Error: {err}"); process::exit(1); } diff --git a/cfgsync/runtime/src/bin/cfgsync-server.rs b/cfgsync/runtime/src/bin/cfgsync-server.rs index a719044..51db99b 100644 --- a/cfgsync/runtime/src/bin/cfgsync-server.rs +++ b/cfgsync/runtime/src/bin/cfgsync-server.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use cfgsync_runtime::serve_cfgsync_from_config; +use cfgsync_runtime::serve_from_config; use clap::Parser; #[derive(Parser, Debug)] @@ -12,5 +12,5 @@ struct Args { #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Args::parse(); - serve_cfgsync_from_config(&args.config).await + serve_from_config(&args.config).await } diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 510da04..e9a19da 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -19,11 +19,11 @@ const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); /// Output routing for fetched artifact files. #[derive(Debug, Clone, Default)] -pub struct ArtifactOutputMap { +pub struct OutputMap { routes: HashMap, } -impl ArtifactOutputMap { +impl OutputMap { /// Creates an empty artifact output map. #[must_use] pub fn new() -> Self { @@ -49,101 +49,142 @@ impl ArtifactOutputMap { } } +/// Runtime-oriented cfgsync client that handles registration, fetch, and local +/// artifact materialization. +#[derive(Debug, Clone)] +pub struct Client { + inner: CfgsyncClient, +} + +impl Client { + /// Creates a runtime client that talks to the cfgsync server at + /// `server_addr`. + #[must_use] + pub fn new(server_addr: &str) -> Self { + Self { + inner: CfgsyncClient::new(server_addr), + } + } + + /// Registers a node and fetches its artifact payload from cfgsync. + pub async fn register_and_fetch( + &self, + registration: &NodeRegistration, + ) -> Result { + self.register_node(registration).await?; + + let payload = self + .fetch_with_retry(registration) + .await + .context("fetching node artifacts")?; + ensure_schema_version(&payload)?; + + Ok(payload) + } + + /// Registers a node, fetches its artifact payload, and writes the result + /// using the provided output routing policy. + pub async fn fetch_and_write( + &self, + registration: &NodeRegistration, + outputs: &OutputMap, + ) -> Result<()> { + let payload = self.register_and_fetch(registration).await?; + let files = collect_payload_files(&payload)?; + + for file in files { + write_file(file, outputs)?; + } + + info!(files = files.len(), "cfgsync files saved"); + + Ok(()) + } + + async fn fetch_with_retry( + &self, + registration: &NodeRegistration, + ) -> Result { + for attempt in 1..=FETCH_ATTEMPTS { + match self.fetch_once(registration).await { + Ok(config) => return Ok(config), + Err(error) => { + if attempt == FETCH_ATTEMPTS { + return Err(error).with_context(|| { + format!("fetching node artifacts after {attempt} attempts") + }); + } + + sleep(FETCH_RETRY_DELAY).await; + } + } + } + + unreachable!("cfgsync fetch loop always returns before exhausting attempts"); + } + + async fn fetch_once(&self, registration: &NodeRegistration) -> Result { + self.inner + .fetch_node_config(registration) + .await + .map_err(Into::into) + } + + async fn register_node(&self, registration: &NodeRegistration) -> Result<()> { + for attempt in 1..=FETCH_ATTEMPTS { + match self.inner.register_node(registration).await { + Ok(()) => { + info!(identifier = %registration.identifier, "cfgsync node registered"); + return Ok(()); + } + Err(error) => { + if attempt == FETCH_ATTEMPTS { + return Err(error).with_context(|| { + format!("registering node with cfgsync after {attempt} attempts") + }); + } + + sleep(FETCH_RETRY_DELAY).await; + } + } + } + + unreachable!("cfgsync register loop always returns before exhausting attempts"); + } +} + #[derive(Debug, Error)] enum ClientEnvError { #[error("CFG_HOST_IP `{value}` is not a valid IPv4 address")] InvalidIp { value: String }, } -async fn fetch_with_retry( - payload: &NodeRegistration, - server_addr: &str, -) -> Result { - let client = CfgsyncClient::new(server_addr); - - for attempt in 1..=FETCH_ATTEMPTS { - match fetch_once(&client, payload).await { - Ok(config) => return Ok(config), - Err(error) => { - if attempt == FETCH_ATTEMPTS { - return Err(error).with_context(|| { - format!("fetching cfgsync payload after {attempt} attempts") - }); - } - - sleep(FETCH_RETRY_DELAY).await; - } - } - } - - unreachable!("cfgsync fetch loop always returns before exhausting attempts"); -} - -async fn fetch_once( - client: &CfgsyncClient, - payload: &NodeRegistration, -) -> Result { - let response = client.fetch_node_config(payload).await?; - - Ok(response) -} - -async fn register_node(payload: &NodeRegistration, server_addr: &str) -> Result<()> { - let client = CfgsyncClient::new(server_addr); - - for attempt in 1..=FETCH_ATTEMPTS { - match client.register_node(payload).await { - Ok(()) => { - info!(identifier = %payload.identifier, "cfgsync node registered"); - return Ok(()); - } - Err(error) => { - if attempt == FETCH_ATTEMPTS { - return Err(error).with_context(|| { - format!("registering node with cfgsync after {attempt} attempts") - }); - } - - sleep(FETCH_RETRY_DELAY).await; - } - } - } - - unreachable!("cfgsync register loop always returns before exhausting attempts"); -} - /// Registers a node and fetches its artifact payload from cfgsync. -pub async fn register_and_fetch_artifacts( +/// +/// Prefer [`Client::register_and_fetch`] when you already hold a runtime +/// client value. +pub async fn register_and_fetch( registration: &NodeRegistration, server_addr: &str, ) -> Result { - register_node(registration, server_addr).await?; - - let payload = fetch_with_retry(registration, server_addr) + Client::new(server_addr) + .register_and_fetch(registration) .await - .context("fetching cfgsync node config")?; - ensure_schema_version(&payload)?; - - Ok(payload) } /// Registers a node, fetches its artifact payload, and writes the files using /// the provided output routing policy. -pub async fn fetch_and_write_artifacts( +/// +/// Prefer [`Client::fetch_and_write`] when you already hold a runtime client +/// value. +pub async fn fetch_and_write( registration: &NodeRegistration, server_addr: &str, - outputs: &ArtifactOutputMap, + outputs: &OutputMap, ) -> Result<()> { - let payload = register_and_fetch_artifacts(registration, server_addr).await?; - let files = collect_payload_files(&payload)?; - - for file in files { - write_cfgsync_file(file, outputs)?; - } - - info!(files = files.len(), "cfgsync files saved"); - - Ok(()) + Client::new(server_addr) + .fetch_and_write(registration, outputs) + .await } fn ensure_schema_version(config: &NodeArtifactsPayload) -> Result<()> { @@ -166,7 +207,7 @@ fn collect_payload_files(config: &NodeArtifactsPayload) -> Result<&[NodeArtifact Ok(config.files()) } -fn write_cfgsync_file(file: &NodeArtifactFile, outputs: &ArtifactOutputMap) -> Result<()> { +fn write_file(file: &NodeArtifactFile, outputs: &OutputMap) -> Result<()> { let path = outputs.resolve_path(file); ensure_parent_dir(&path)?; @@ -193,8 +234,8 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { Ok(()) } -/// Resolves cfgsync client inputs from environment and materializes node files. -pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { +/// Resolves runtime client inputs from environment and materializes node files. +pub async fn run_client_from_env(default_port: u16) -> Result<()> { let server_addr = env::var("CFG_SERVER_ADDR").unwrap_or_else(|_| format!("http://127.0.0.1:{default_port}")); let ip = parse_ip_env(&env::var("CFG_HOST_IP").unwrap_or_else(|_| "127.0.0.1".to_owned()))?; @@ -203,7 +244,7 @@ pub async fn run_cfgsync_client_from_env(default_port: u16) -> Result<()> { let metadata = parse_registration_payload_env()?; let outputs = build_output_map(); - fetch_and_write_artifacts( + fetch_and_write( &NodeRegistration::new(identifier, ip).with_payload(metadata), &server_addr, &outputs, @@ -232,8 +273,8 @@ fn parse_registration_payload(raw: &str) -> Result { RegistrationPayload::from_json_str(raw).context("parsing CFG_REGISTRATION_METADATA_JSON") } -fn build_output_map() -> ArtifactOutputMap { - let mut outputs = ArtifactOutputMap::default(); +fn build_output_map() -> OutputMap { + let mut outputs = OutputMap::default(); if let Ok(path) = env::var("CFG_FILE_PATH") { outputs = outputs @@ -255,7 +296,6 @@ fn build_output_map() -> ArtifactOutputMap { mod tests { use cfgsync_core::{ CfgsyncServerState, NodeArtifactsBundle, NodeArtifactsBundleEntry, StaticConfigSource, - serve_cfgsync, }; use tempfile::tempdir; @@ -280,15 +320,15 @@ mod tests { let port = allocate_test_port(); let address = format!("http://127.0.0.1:{port}"); let server = tokio::spawn(async move { - serve_cfgsync(port, state) + cfgsync_core::serve_cfgsync(port, state) .await .expect("run cfgsync server"); }); - fetch_and_write_artifacts( + fetch_and_write( &NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), &address, - &ArtifactOutputMap::default(), + &OutputMap::default(), ) .await .expect("pull config files"); diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index fcca208..ab6032b 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -3,12 +3,8 @@ pub use cfgsync_core as core; mod client; mod server; -pub use client::{ - ArtifactOutputMap, fetch_and_write_artifacts, register_and_fetch_artifacts, - run_cfgsync_client_from_env, -}; +pub use client::{Client, OutputMap, fetch_and_write, register_and_fetch, run_client_from_env}; pub use server::{ - CfgsyncServerConfig, CfgsyncServerSource, LoadCfgsyncServerConfigError, build_cfgsync_router, - build_persisted_cfgsync_router, serve_cfgsync, serve_cfgsync_from_config, - serve_persisted_cfgsync, + LoadServerConfigError, ServerConfig, ServerSource, build_persisted_router, build_router, serve, + serve_from_config, serve_persisted, }; diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 27e9a27..931a088 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -15,11 +15,11 @@ use thiserror::Error; /// Runtime cfgsync server config loaded from YAML. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct CfgsyncServerConfig { +pub struct ServerConfig { /// HTTP port to bind the cfgsync server on. pub port: u16, /// Source used by the runtime-managed cfgsync server. - pub source: CfgsyncServerSource, + pub source: ServerSource, } /// Runtime cfgsync source loaded from config. @@ -31,7 +31,7 @@ pub struct CfgsyncServerConfig { /// before receiving already-materialized artifacts #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum CfgsyncServerSource { +pub enum ServerSource { /// Serve a static precomputed artifact bundle directly. Bundle { bundle_path: String }, /// Require node registration before serving precomputed artifacts. @@ -39,7 +39,7 @@ pub enum CfgsyncServerSource { } #[derive(Debug, Error)] -pub enum LoadCfgsyncServerConfigError { +pub enum LoadServerConfigError { #[error("failed to read cfgsync config file {path}: {source}")] Read { path: String, @@ -54,23 +54,22 @@ pub enum LoadCfgsyncServerConfigError { }, } -impl CfgsyncServerConfig { +impl ServerConfig { /// Loads cfgsync runtime server config from a YAML file. - pub fn load_from_file(path: &Path) -> Result { + pub fn load_from_file(path: &Path) -> Result { let config_path = path.display().to_string(); let config_content = - fs::read_to_string(path).map_err(|source| LoadCfgsyncServerConfigError::Read { + fs::read_to_string(path).map_err(|source| LoadServerConfigError::Read { path: config_path.clone(), source, })?; - let config: CfgsyncServerConfig = - serde_yaml::from_str(&config_content).map_err(|source| { - LoadCfgsyncServerConfigError::Parse { - path: config_path, - source, - } - })?; + let config: ServerConfig = serde_yaml::from_str(&config_content).map_err(|source| { + LoadServerConfigError::Parse { + path: config_path, + source, + } + })?; Ok(config) } @@ -79,7 +78,7 @@ impl CfgsyncServerConfig { pub fn for_bundle(port: u16, bundle_path: impl Into) -> Self { Self { port, - source: CfgsyncServerSource::Bundle { + source: ServerSource::Bundle { bundle_path: bundle_path.into(), }, } @@ -91,7 +90,7 @@ impl CfgsyncServerConfig { pub fn for_registration(port: u16, artifacts_path: impl Into) -> Self { Self { port, - source: CfgsyncServerSource::Registration { + source: ServerSource::Registration { artifacts_path: artifacts_path.into(), }, } @@ -143,8 +142,8 @@ fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::Path } /// Loads runtime config and starts cfgsync HTTP server process. -pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> { - let config = CfgsyncServerConfig::load_from_file(config_path)?; +pub async fn serve_from_config(config_path: &Path) -> anyhow::Result<()> { + let config = ServerConfig::load_from_file(config_path)?; let bundle_path = resolve_source_path(config_path, &config.source); let state = build_server_state(&config, &bundle_path)?; @@ -162,7 +161,7 @@ pub async fn serve_cfgsync_from_config(config_path: &Path) -> anyhow::Result<()> /// - artifact serving /// /// while the app owns only snapshot materialization logic. -pub fn build_cfgsync_router(materializer: M) -> Router +pub fn build_router(materializer: M) -> Router where M: RegistrationSnapshotMaterializer + 'static, { @@ -175,7 +174,7 @@ where /// /// Use this when the application wants cfgsync to persist or publish shared /// artifacts after a snapshot becomes ready. -pub fn build_persisted_cfgsync_router(materializer: M, sink: S) -> Router +pub fn build_persisted_router(materializer: M, sink: S) -> Router where M: RegistrationSnapshotMaterializer + 'static, S: MaterializedArtifactsSink + 'static, @@ -192,11 +191,11 @@ where /// /// This is the simplest runtime entrypoint when the application already has a /// materializer value and does not need to compose extra routes. -pub async fn serve_cfgsync(port: u16, materializer: M) -> Result<(), RunCfgsyncError> +pub async fn serve(port: u16, materializer: M) -> Result<(), RunCfgsyncError> where M: RegistrationSnapshotMaterializer + 'static, { - let router = build_cfgsync_router(materializer); + let router = build_router(materializer); serve_router(port, router).await } @@ -204,8 +203,8 @@ where /// materialization results. /// /// This is the direct serving counterpart to -/// [`build_persisted_cfgsync_router`]. -pub async fn serve_persisted_cfgsync( +/// [`build_persisted_router`]. +pub async fn serve_persisted( port: u16, materializer: M, sink: S, @@ -214,7 +213,7 @@ where M: RegistrationSnapshotMaterializer + 'static, S: MaterializedArtifactsSink + 'static, { - let router = build_persisted_cfgsync_router(materializer, sink); + let router = build_persisted_router(materializer, sink); serve_router(port, router).await } @@ -232,23 +231,21 @@ async fn serve_router(port: u16, router: Router) -> Result<(), RunCfgsyncError> } fn build_server_state( - config: &CfgsyncServerConfig, + config: &ServerConfig, source_path: &Path, ) -> anyhow::Result { let repo = match &config.source { - CfgsyncServerSource::Bundle { .. } => load_bundle_provider(source_path)?, - CfgsyncServerSource::Registration { .. } => load_registration_source(source_path)?, + ServerSource::Bundle { .. } => load_bundle_provider(source_path)?, + ServerSource::Registration { .. } => load_registration_source(source_path)?, }; Ok(CfgsyncServerState::new(repo)) } -fn resolve_source_path(config_path: &Path, source: &CfgsyncServerSource) -> std::path::PathBuf { +fn resolve_source_path(config_path: &Path, source: &ServerSource) -> std::path::PathBuf { match source { - CfgsyncServerSource::Bundle { bundle_path } => { - resolve_bundle_path(config_path, bundle_path) - } - CfgsyncServerSource::Registration { artifacts_path } => { + ServerSource::Bundle { bundle_path } => resolve_bundle_path(config_path, bundle_path), + ServerSource::Registration { artifacts_path } => { resolve_bundle_path(config_path, artifacts_path) } } From 4712f93a68c0ec29002a6b74b03d9a89bfb6f4ec Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 09:54:34 +0100 Subject: [PATCH 34/38] Use runtime client as primary cfgsync API --- cfgsync/README.md | 6 ++- cfgsync/runtime/examples/minimal_cfgsync.rs | 6 ++- cfgsync/runtime/src/client.rs | 54 +++++---------------- cfgsync/runtime/src/lib.rs | 2 +- 4 files changed, 22 insertions(+), 46 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index c113019..899d32c 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -238,14 +238,16 @@ serve(4400, MyMaterializer).await?; Fetching and writing artifacts: ```rust -use cfgsync_runtime::{OutputMap, fetch_and_write}; +use cfgsync_runtime::{Client, OutputMap}; # async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { let outputs = OutputMap::new() .route("/config.yaml", "/node-data/node-1/config.yaml") .route("deployment-settings.yaml", "/node-data/shared/deployment-settings.yaml"); -fetch_and_write(®istration, "http://127.0.0.1:4400", &outputs).await?; +Client::new("http://127.0.0.1:4400") + .fetch_and_write(®istration, &outputs) + .await?; # Ok(()) # } ``` diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs index b2aabda..d61f1d7 100644 --- a/cfgsync/runtime/examples/minimal_cfgsync.rs +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -4,7 +4,7 @@ use cfgsync_adapter::{ }; use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; use cfgsync_core::NodeRegistration; -use cfgsync_runtime::{OutputMap, fetch_and_write, serve}; +use cfgsync_runtime::{Client, OutputMap, serve}; use tempfile::tempdir; use tokio::time::{Duration, sleep}; @@ -48,7 +48,9 @@ async fn main() -> anyhow::Result<()> { let outputs = OutputMap::new().route("/config.yaml", &config_path); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse()?); - fetch_and_write(®istration, "http://127.0.0.1:4400", &outputs).await?; + Client::new("http://127.0.0.1:4400") + .fetch_and_write(®istration, &outputs) + .await?; println!("{}", std::fs::read_to_string(&config_path)?); diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index e9a19da..04484fa 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -159,34 +159,6 @@ enum ClientEnvError { InvalidIp { value: String }, } -/// Registers a node and fetches its artifact payload from cfgsync. -/// -/// Prefer [`Client::register_and_fetch`] when you already hold a runtime -/// client value. -pub async fn register_and_fetch( - registration: &NodeRegistration, - server_addr: &str, -) -> Result { - Client::new(server_addr) - .register_and_fetch(registration) - .await -} - -/// Registers a node, fetches its artifact payload, and writes the files using -/// the provided output routing policy. -/// -/// Prefer [`Client::fetch_and_write`] when you already hold a runtime client -/// value. -pub async fn fetch_and_write( - registration: &NodeRegistration, - server_addr: &str, - outputs: &OutputMap, -) -> Result<()> { - Client::new(server_addr) - .fetch_and_write(registration, outputs) - .await -} - fn ensure_schema_version(config: &NodeArtifactsPayload) -> Result<()> { if config.schema_version != CFGSYNC_SCHEMA_VERSION { bail!( @@ -244,12 +216,12 @@ pub async fn run_client_from_env(default_port: u16) -> Result<()> { let metadata = parse_registration_payload_env()?; let outputs = build_output_map(); - fetch_and_write( - &NodeRegistration::new(identifier, ip).with_payload(metadata), - &server_addr, - &outputs, - ) - .await + Client::new(&server_addr) + .fetch_and_write( + &NodeRegistration::new(identifier, ip).with_payload(metadata), + &outputs, + ) + .await } fn parse_ip_env(ip_str: &str) -> Result { @@ -325,13 +297,13 @@ mod tests { .expect("run cfgsync server"); }); - fetch_and_write( - &NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), - &address, - &OutputMap::default(), - ) - .await - .expect("pull config files"); + Client::new(&address) + .fetch_and_write( + &NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), + &OutputMap::default(), + ) + .await + .expect("pull config files"); server.abort(); let _ = server.await; diff --git a/cfgsync/runtime/src/lib.rs b/cfgsync/runtime/src/lib.rs index ab6032b..479091e 100644 --- a/cfgsync/runtime/src/lib.rs +++ b/cfgsync/runtime/src/lib.rs @@ -3,7 +3,7 @@ pub use cfgsync_core as core; mod client; mod server; -pub use client::{Client, OutputMap, fetch_and_write, register_and_fetch, run_client_from_env}; +pub use client::{Client, OutputMap, run_client_from_env}; pub use server::{ LoadServerConfigError, ServerConfig, ServerSource, build_persisted_router, build_router, serve, serve_from_config, serve_persisted, From 6218d4070c7d4c7156aee2dffbeb8586f18576d3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 10:00:10 +0100 Subject: [PATCH 35/38] Polish cfgsync runtime ergonomics --- cfgsync/README.md | 37 ++++++++++++++--------- cfgsync/runtime/src/client.rs | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 899d32c..83ca201 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -143,6 +143,24 @@ What does not belong in cfgsync core is equally important. Generic cfgsync shoul Those belong in the adapter or in the consuming application. +## Start here + +If you want the shortest path into the library, start with the end-to-end +runtime example: + +- `cfgsync/runtime/examples/minimal_cfgsync.rs` + +It shows the full loop: + +- define a snapshot materializer +- serve cfgsync +- register a node +- fetch artifacts +- write them locally + +After that, the only concepts you usually need to learn are the ones in the +next section. + ## Minimal integration path For a new application, the shortest sensible path is: @@ -155,18 +173,6 @@ For a new application, the shortest sensible path is: That gives you the main value of the library without forcing extra application logic into cfgsync itself. -## Minimal example - -A minimal standalone example lives in: - -- `cfgsync/runtime/examples/minimal_cfgsync.rs` - -It shows the smallest complete path: - -- registration snapshot materializer -- cfgsync server -- node artifact generation - ## Code sketch Typed registration payload: @@ -241,9 +247,10 @@ Fetching and writing artifacts: use cfgsync_runtime::{Client, OutputMap}; # async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> { -let outputs = OutputMap::new() - .route("/config.yaml", "/node-data/node-1/config.yaml") - .route("deployment-settings.yaml", "/node-data/shared/deployment-settings.yaml"); +let outputs = OutputMap::config_and_shared( + "/node-data/node-1/config.yaml", + "/node-data/shared", +); Client::new("http://127.0.0.1:4400") .fetch_and_write(®istration, &outputs) diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 04484fa..c1c4994 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -21,6 +21,13 @@ const FETCH_RETRY_DELAY: Duration = Duration::from_millis(250); #[derive(Debug, Clone, Default)] pub struct OutputMap { routes: HashMap, + fallback: Option, +} + +#[derive(Debug, Clone)] +enum FallbackRoute { + Under(PathBuf), + Shared { dir: PathBuf }, } impl OutputMap { @@ -41,12 +48,62 @@ impl OutputMap { self } + /// Writes payload files under `root`, preserving each artifact path. + /// + /// For example, `/config.yaml` is written to `/config.yaml` and + /// `shared/deployment-settings.yaml` is written to + /// `/shared/deployment-settings.yaml`. + #[must_use] + pub fn under(root: impl Into) -> Self { + Self { + routes: HashMap::new(), + fallback: Some(FallbackRoute::Under(root.into())), + } + } + + /// Writes the node config to `config_path` and all other files under + /// `shared_dir`, preserving their relative artifact paths. + #[must_use] + pub fn config_and_shared( + config_path: impl Into, + shared_dir: impl Into, + ) -> Self { + let config_path = config_path.into(); + let shared_dir = shared_dir.into(); + + Self::default() + .route("/config.yaml", config_path.clone()) + .route("config.yaml", config_path) + .with_fallback(FallbackRoute::Shared { dir: shared_dir }) + } + fn resolve_path(&self, file: &NodeArtifactFile) -> PathBuf { self.routes .get(&file.path) .cloned() + .or_else(|| { + self.fallback + .as_ref() + .map(|fallback| fallback.resolve(&file.path)) + }) .unwrap_or_else(|| PathBuf::from(&file.path)) } + + fn with_fallback(mut self, fallback: FallbackRoute) -> Self { + self.fallback = Some(fallback); + self + } +} + +impl FallbackRoute { + fn resolve(&self, artifact_path: &str) -> PathBuf { + let relative = artifact_path.trim_start_matches('/'); + + match self { + FallbackRoute::Under(root) => root.join(relative), + FallbackRoute::Shared { dir } => dir.join(relative), + } + } } /// Runtime-oriented cfgsync client that handles registration, fetch, and local From 96dc957881f9ff89e9dc9024d2da30314c1c44bb Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 10:03:02 +0100 Subject: [PATCH 36/38] Add focused cfgsync examples --- cfgsync/README.md | 31 ++++--- cfgsync/runtime/examples/minimal_cfgsync.rs | 8 +- .../precomputed_registration_cfgsync.rs | 73 +++++++++++++++++ .../wait_for_registrations_cfgsync.rs | 81 +++++++++++++++++++ 4 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 cfgsync/runtime/examples/precomputed_registration_cfgsync.rs create mode 100644 cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs diff --git a/cfgsync/README.md b/cfgsync/README.md index 83ca201..abeaed4 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -145,21 +145,19 @@ Those belong in the adapter or in the consuming application. ## Start here -If you want the shortest path into the library, start with the end-to-end -runtime example: +Start with the examples in `cfgsync/runtime/examples/`. -- `cfgsync/runtime/examples/minimal_cfgsync.rs` +- `minimal_cfgsync.rs` shows the smallest complete flow: serve cfgsync, register + one node, fetch artifacts, and write them locally. +- `precomputed_registration_cfgsync.rs` shows how precomputed artifacts still + use the same registration flow, including a later node that joins after the + server is already running. +- `wait_for_registrations_cfgsync.rs` shows the normal `NotReady` path: one node + waits until the materializer sees enough registrations, then both nodes + receive config. -It shows the full loop: - -- define a snapshot materializer -- serve cfgsync -- register a node -- fetch artifacts -- write them locally - -After that, the only concepts you usually need to learn are the ones in the -next section. +Those three examples cover the full public model. The rest of this README just +names the pieces and explains where application-specific logic belongs. ## Minimal integration path @@ -169,11 +167,12 @@ For a new application, the shortest sensible path is: 2. implement `RegistrationSnapshotMaterializer` 3. return node-local and optional shared artifacts 4. serve them with `serve(...)` -5. use `CfgsyncClient` or the runtime helpers on the node side +5. use `Client` on the node side -That gives you the main value of the library without forcing extra application logic into cfgsync itself. +That gives you the main value of the library without pushing application logic +into cfgsync itself. -## Code sketch +## API sketch Typed registration payload: diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs index d61f1d7..38db4f0 100644 --- a/cfgsync/runtime/examples/minimal_cfgsync.rs +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -44,15 +44,17 @@ async fn main() -> anyhow::Result<()> { sleep(Duration::from_millis(100)).await; let tempdir = tempdir()?; - let config_path = tempdir.path().join("config.yaml"); - let outputs = OutputMap::new().route("/config.yaml", &config_path); + let outputs = OutputMap::under(tempdir.path()); let registration = NodeRegistration::new("node-1", "127.0.0.1".parse()?); Client::new("http://127.0.0.1:4400") .fetch_and_write(®istration, &outputs) .await?; - println!("{}", std::fs::read_to_string(&config_path)?); + println!( + "{}", + std::fs::read_to_string(tempdir.path().join("config.yaml"))? + ); server.abort(); Ok(()) diff --git a/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs b/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs new file mode 100644 index 0000000..dc5774b --- /dev/null +++ b/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs @@ -0,0 +1,73 @@ +use cfgsync_adapter::MaterializedArtifacts; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; +use cfgsync_core::NodeRegistration; +use cfgsync_runtime::{Client, OutputMap, serve}; +use tempfile::tempdir; +use tokio::time::{Duration, sleep}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let port = 4401; + let artifacts = MaterializedArtifacts::from_nodes([ + ( + "node-1".to_owned(), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "id: node-1\n")]), + ), + ( + "node-2".to_owned(), + ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "id: node-2\n")]), + ), + ]) + .with_shared(ArtifactSet::new(vec![ArtifactFile::new( + "/shared/cluster.yaml", + "cluster: demo\n", + )])); + + let server = tokio::spawn(async move { serve(port, artifacts).await }); + + // Give the server a moment to bind before clients register. + sleep(Duration::from_millis(100)).await; + + let node_1_dir = tempdir()?; + let node_1_outputs = OutputMap::config_and_shared( + node_1_dir.path().join("config.yaml"), + node_1_dir.path().join("shared"), + ); + let node_1 = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + + Client::new("http://127.0.0.1:4401") + .fetch_and_write(&node_1, &node_1_outputs) + .await?; + + println!( + "node-1 config:\n{}", + std::fs::read_to_string(node_1_dir.path().join("config.yaml"))? + ); + + // A later node still uses the same registration/fetch flow. The artifacts + // were already known; registration only gates delivery. + sleep(Duration::from_millis(250)).await; + + let node_2_dir = tempdir()?; + let node_2_outputs = OutputMap::config_and_shared( + node_2_dir.path().join("config.yaml"), + node_2_dir.path().join("shared"), + ); + let node_2 = NodeRegistration::new("node-2", "127.0.0.2".parse()?); + + Client::new("http://127.0.0.1:4401") + .fetch_and_write(&node_2, &node_2_outputs) + .await?; + + println!( + "node-2 config:\n{}", + std::fs::read_to_string(node_2_dir.path().join("config.yaml"))? + ); + println!( + "shared artifact:\n{}", + std::fs::read_to_string(node_2_dir.path().join("shared/shared/cluster.yaml"))? + ); + + server.abort(); + Ok(()) +} diff --git a/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs b/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs new file mode 100644 index 0000000..6e25151 --- /dev/null +++ b/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs @@ -0,0 +1,81 @@ +use cfgsync_adapter::{ + DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot, + RegistrationSnapshotMaterializer, +}; +use cfgsync_artifacts::{ArtifactFile, ArtifactSet}; +use cfgsync_core::NodeRegistration; +use cfgsync_runtime::{Client, OutputMap, serve}; +use tempfile::tempdir; +use tokio::time::{Duration, sleep}; + +struct ThresholdMaterializer; + +impl RegistrationSnapshotMaterializer for ThresholdMaterializer { + fn materialize_snapshot( + &self, + registrations: &RegistrationSnapshot, + ) -> Result { + if registrations.len() < 2 { + return Ok(MaterializationResult::NotReady); + } + + let nodes = registrations.iter().map(|registration| { + ( + registration.identifier.clone(), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml", + format!("id: {}\ncluster_ready: true\n", registration.identifier), + )]), + ) + }); + + Ok(MaterializationResult::ready( + MaterializedArtifacts::from_nodes(nodes), + )) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let port = 4402; + let server = tokio::spawn(async move { serve(port, ThresholdMaterializer).await }); + + sleep(Duration::from_millis(100)).await; + + let waiting_dir = tempdir()?; + let waiting_outputs = OutputMap::under(waiting_dir.path()); + let waiting_node = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + let waiting_client = Client::new("http://127.0.0.1:4402"); + + let waiting_task = tokio::spawn(async move { + waiting_client + .fetch_and_write(&waiting_node, &waiting_outputs) + .await + }); + + // node-1 is now polling. The materializer will keep returning NotReady + // until node-2 registers. + sleep(Duration::from_millis(400)).await; + + let second_dir = tempdir()?; + let second_outputs = OutputMap::under(second_dir.path()); + let second_node = NodeRegistration::new("node-2", "127.0.0.2".parse()?); + + Client::new("http://127.0.0.1:4402") + .fetch_and_write(&second_node, &second_outputs) + .await?; + + waiting_task.await??; + + println!( + "node-1 config after threshold reached:\n{}", + std::fs::read_to_string(waiting_dir.path().join("config.yaml"))? + ); + println!( + "node-2 config:\n{}", + std::fs::read_to_string(second_dir.path().join("config.yaml"))? + ); + + server.abort(); + Ok(()) +} From 8db21f53dd084bd06b59a9500e612eb2a46cb5ca Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 10:07:22 +0100 Subject: [PATCH 37/38] Polish cfgsync public runtime surface --- cfgsync/README.md | 2 +- cfgsync/core/src/client.rs | 4 ++-- cfgsync/core/src/compat.rs | 2 +- cfgsync/core/src/lib.rs | 2 +- cfgsync/runtime/src/client.rs | 6 ++--- cfgsync/runtime/src/server.rs | 43 ++++++++++++++++++++--------------- 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index abeaed4..1f58e08 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -60,7 +60,7 @@ Important types here are: - `NodeRegistration` - `RegistrationPayload` - `NodeArtifactsPayload` -- `CfgsyncClient` +- `Client` - `NodeConfigSource` It also defines the HTTP contract: diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index d682936..444e070 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -31,12 +31,12 @@ pub enum ConfigFetchStatus { /// Reusable HTTP client for cfgsync server endpoints. #[derive(Clone, Debug)] -pub struct CfgsyncClient { +pub struct Client { base_url: String, http: reqwest::Client, } -impl CfgsyncClient { +impl Client { /// Creates a cfgsync client pointed at the given server base URL. #[must_use] pub fn new(base_url: impl Into) -> Self { diff --git a/cfgsync/core/src/compat.rs b/cfgsync/core/src/compat.rs index ea1cb17..d495592 100644 --- a/cfgsync/core/src/compat.rs +++ b/cfgsync/core/src/compat.rs @@ -2,7 +2,7 @@ pub use crate::{ bundle::{NodeArtifactsBundle as CfgSyncBundle, NodeArtifactsBundleEntry as CfgSyncBundleNode}, - client::CfgsyncClient as CfgSyncClient, + client::Client as CfgSyncClient, protocol::{ CfgsyncErrorCode as CfgSyncErrorCode, CfgsyncErrorResponse as CfgSyncErrorResponse, ConfigResolveResponse as RepoResponse, NodeArtifactFile as CfgSyncFile, diff --git a/cfgsync/core/src/lib.rs b/cfgsync/core/src/lib.rs index 4e01aad..e855acb 100644 --- a/cfgsync/core/src/lib.rs +++ b/cfgsync/core/src/lib.rs @@ -8,7 +8,7 @@ pub mod server; pub mod source; pub use bundle::{NodeArtifactsBundle, NodeArtifactsBundleEntry}; -pub use client::{CfgsyncClient, ClientError, ConfigFetchStatus}; +pub use client::{Client, ClientError, ConfigFetchStatus}; pub use protocol::{ CFGSYNC_SCHEMA_VERSION, CfgsyncErrorCode, CfgsyncErrorResponse, ConfigResolveResponse, NodeArtifactFile, NodeArtifactsPayload, NodeRegistration, RegisterNodeResponse, diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index c1c4994..3cd8091 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -7,7 +7,7 @@ use std::{ use anyhow::{Context as _, Result, bail}; use cfgsync_core::{ - CFGSYNC_SCHEMA_VERSION, CfgsyncClient, NodeArtifactFile, NodeArtifactsPayload, + CFGSYNC_SCHEMA_VERSION, Client as ProtocolClient, NodeArtifactFile, NodeArtifactsPayload, NodeRegistration, RegistrationPayload, }; use thiserror::Error; @@ -110,7 +110,7 @@ impl FallbackRoute { /// artifact materialization. #[derive(Debug, Clone)] pub struct Client { - inner: CfgsyncClient, + inner: ProtocolClient, } impl Client { @@ -119,7 +119,7 @@ impl Client { #[must_use] pub fn new(server_addr: &str) -> Self { Self { - inner: CfgsyncClient::new(server_addr), + inner: ProtocolClient::new(server_addr), } } diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index 931a088..eb33704 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -25,15 +25,16 @@ pub struct ServerConfig { /// Runtime cfgsync source loaded from config. /// /// This type is intentionally runtime-oriented: -/// - `Bundle` serves a static precomputed bundle directly +/// - `Static` serves precomputed artifacts directly without registration /// - `Registration` serves precomputed artifacts through the registration /// protocol, which is useful when the consumer wants clients to register /// before receiving already-materialized artifacts #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ServerSource { - /// Serve a static precomputed artifact bundle directly. - Bundle { bundle_path: String }, + /// Serve precomputed artifacts directly, without requiring registration. + #[serde(alias = "bundle")] + Static { artifacts_path: String }, /// Require node registration before serving precomputed artifacts. Registration { artifacts_path: String }, } @@ -75,17 +76,17 @@ impl ServerConfig { } #[must_use] - pub fn for_bundle(port: u16, bundle_path: impl Into) -> Self { + pub fn for_static(port: u16, artifacts_path: impl Into) -> Self { Self { port, - source: ServerSource::Bundle { - bundle_path: bundle_path.into(), + source: ServerSource::Static { + artifacts_path: artifacts_path.into(), }, } } - /// Builds a config that serves a static bundle behind the registration - /// flow. + /// Builds a config that serves precomputed artifacts through the + /// registration flow. #[must_use] pub fn for_registration(port: u16, artifacts_path: impl Into) -> Self { Self { @@ -97,9 +98,13 @@ impl ServerConfig { } } -fn load_bundle_provider(bundle_path: &Path) -> anyhow::Result> { - let provider = BundleConfigSource::from_yaml_file(bundle_path) - .with_context(|| format!("loading cfgsync provider from {}", bundle_path.display()))?; +fn load_static_source(artifacts_path: &Path) -> anyhow::Result> { + let provider = BundleConfigSource::from_yaml_file(artifacts_path).with_context(|| { + format!( + "loading cfgsync static artifacts from {}", + artifacts_path.display() + ) + })?; Ok(Arc::new(provider)) } @@ -129,8 +134,8 @@ fn load_materialized_artifacts_yaml( }) } -fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::PathBuf { - let path = Path::new(bundle_path); +fn resolve_artifacts_path(config_path: &Path, artifacts_path: &str) -> std::path::PathBuf { + let path = Path::new(artifacts_path); if path.is_absolute() { return path.to_path_buf(); } @@ -144,9 +149,9 @@ fn resolve_bundle_path(config_path: &Path, bundle_path: &str) -> std::path::Path /// Loads runtime config and starts cfgsync HTTP server process. pub async fn serve_from_config(config_path: &Path) -> anyhow::Result<()> { let config = ServerConfig::load_from_file(config_path)?; - let bundle_path = resolve_source_path(config_path, &config.source); + let artifacts_path = resolve_source_path(config_path, &config.source); - let state = build_server_state(&config, &bundle_path)?; + let state = build_server_state(&config, &artifacts_path)?; serve_cfgsync_state(config.port, state).await?; Ok(()) @@ -235,7 +240,7 @@ fn build_server_state( source_path: &Path, ) -> anyhow::Result { let repo = match &config.source { - ServerSource::Bundle { .. } => load_bundle_provider(source_path)?, + ServerSource::Static { .. } => load_static_source(source_path)?, ServerSource::Registration { .. } => load_registration_source(source_path)?, }; @@ -244,9 +249,11 @@ fn build_server_state( fn resolve_source_path(config_path: &Path, source: &ServerSource) -> std::path::PathBuf { match source { - ServerSource::Bundle { bundle_path } => resolve_bundle_path(config_path, bundle_path), + ServerSource::Static { artifacts_path } => { + resolve_artifacts_path(config_path, artifacts_path) + } ServerSource::Registration { artifacts_path } => { - resolve_bundle_path(config_path, artifacts_path) + resolve_artifacts_path(config_path, artifacts_path) } } } From f7dba011611c26e4f2278caab19d7c43c04d9696 Mon Sep 17 00:00:00 2001 From: andrussal Date: Thu, 12 Mar 2026 10:24:29 +0100 Subject: [PATCH 38/38] Use concrete string types in cfgsync APIs --- cfgsync/README.md | 2 +- cfgsync/adapter/src/artifacts.rs | 5 +- cfgsync/adapter/src/materializer.rs | 11 ++-- cfgsync/adapter/src/sources.rs | 31 +++++++---- cfgsync/artifacts/src/lib.rs | 7 +-- cfgsync/core/src/client.rs | 4 +- cfgsync/core/src/protocol.rs | 21 +++---- cfgsync/core/src/server.rs | 16 ++++-- cfgsync/core/src/source.rs | 15 +++-- cfgsync/runtime/examples/minimal_cfgsync.rs | 6 +- .../precomputed_registration_cfgsync.rs | 18 ++++-- .../wait_for_registrations_cfgsync.rs | 10 ++-- cfgsync/runtime/src/client.rs | 55 ++++++++++--------- cfgsync/runtime/src/server.rs | 12 ++-- logos/runtime/ext/src/cfgsync/mod.rs | 2 +- testing-framework/core/src/cfgsync/mod.rs | 5 +- 16 files changed, 127 insertions(+), 93 deletions(-) diff --git a/cfgsync/README.md b/cfgsync/README.md index 1f58e08..6e01eee 100644 --- a/cfgsync/README.md +++ b/cfgsync/README.md @@ -185,7 +185,7 @@ struct MyNodeMetadata { api_port: u16, } -let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().unwrap()) +let registration = NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().unwrap()) .with_metadata(&MyNodeMetadata { network_port: 3000, api_port: 18080, diff --git a/cfgsync/adapter/src/artifacts.rs b/cfgsync/adapter/src/artifacts.rs index 3b7c76c..2830b10 100644 --- a/cfgsync/adapter/src/artifacts.rs +++ b/cfgsync/adapter/src/artifacts.rs @@ -16,7 +16,10 @@ pub struct MaterializedArtifacts { impl MaterializedArtifacts { /// Creates materialized artifacts from node-local artifact sets. #[must_use] - pub fn from_nodes(nodes: impl IntoIterator) -> Self { + pub fn from_nodes(nodes: I) -> Self + where + I: IntoIterator, + { Self { nodes: nodes.into_iter().collect(), shared: ArtifactSet::default(), diff --git a/cfgsync/adapter/src/materializer.rs b/cfgsync/adapter/src/materializer.rs index 8c7b991..3681e8c 100644 --- a/cfgsync/adapter/src/materializer.rs +++ b/cfgsync/adapter/src/materializer.rs @@ -208,13 +208,16 @@ mod tests { let nodes = registrations.iter().map(|registration| { ( registration.identifier.clone(), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "ready: true")]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + "ready: true".to_string(), + )]), ) }); Ok(MaterializationResult::ready( MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(vec![ - ArtifactFile::new("/shared.yaml", "cluster: ready"), + ArtifactFile::new("/shared.yaml".to_string(), "cluster: ready".to_string()), ])), )) } @@ -235,7 +238,7 @@ mod tests { fn cached_snapshot_materializer_reuses_previous_result() { let materializer = CachedSnapshotMaterializer::new(CountingMaterializer); let snapshot = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )]); @@ -260,7 +263,7 @@ mod tests { }, ); let snapshot = RegistrationSnapshot::new(vec![cfgsync_core::NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )]); diff --git a/cfgsync/adapter/src/sources.rs b/cfgsync/adapter/src/sources.rs index b3e651d..8e61acf 100644 --- a/cfgsync/adapter/src/sources.rs +++ b/cfgsync/adapter/src/sources.rs @@ -122,11 +122,15 @@ mod tests { fn registration_source_resolves_identifier() { let artifacts = MaterializedArtifacts::from_nodes([( "node-1".to_owned(), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "a: 1")]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + "a: 1".to_string(), + )]), )]); let source = RegistrationConfigSource::new(artifacts); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + let registration = + NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().expect("parse ip")); let _ = source.register(registration.clone()); match source.resolve(®istration) { @@ -139,11 +143,15 @@ mod tests { fn registration_source_reports_not_ready_before_registration() { let artifacts = MaterializedArtifacts::from_nodes([( "node-1".to_owned(), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "a: 1")]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + "a: 1".to_string(), + )]), )]); let source = RegistrationConfigSource::new(artifacts); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + let registration = + NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().expect("parse ip")); match source.resolve(®istration) { ConfigResolveResponse::Config(_) => panic!("expected not-ready"), @@ -168,7 +176,7 @@ mod tests { ( registration.identifier.clone(), ArtifactSet::new(vec![ArtifactFile::new( - "/config.yaml", + "/config.yaml".to_string(), format!("id: {}", registration.identifier), )]), ) @@ -176,7 +184,7 @@ mod tests { Ok(MaterializationResult::ready( MaterializedArtifacts::from_nodes(nodes).with_shared(ArtifactSet::new(vec![ - ArtifactFile::new("/shared.yaml", "cluster: ready"), + ArtifactFile::new("/shared.yaml".to_string(), "cluster: ready".to_string()), ])), )) } @@ -185,8 +193,10 @@ mod tests { #[test] fn registration_source_materializes_from_registration_snapshot() { let source = RegistrationConfigSource::new(ThresholdSnapshotMaterializer); - let node_1 = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); - let node_2 = NodeRegistration::new("node-2", "127.0.0.2".parse().expect("parse ip")); + let node_1 = + NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().expect("parse ip")); + let node_2 = + NodeRegistration::new("node-2".to_string(), "127.0.0.2".parse().expect("parse ip")); let _ = source.register(node_1.clone()); match source.resolve(&node_1) { @@ -224,7 +234,7 @@ mod tests { ( registration.identifier.clone(), ArtifactSet::new(vec![ArtifactFile::new( - "/config.yaml", + "/config.yaml".to_string(), format!("id: {}", registration.identifier), )]), ) @@ -240,7 +250,8 @@ mod tests { calls: AtomicUsize::new(0), }, )); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")); + let registration = + NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().expect("parse ip")); let _ = source.register(registration.clone()); let _ = source.resolve(®istration); diff --git a/cfgsync/artifacts/src/lib.rs b/cfgsync/artifacts/src/lib.rs index 2d1b54c..ef04ce2 100644 --- a/cfgsync/artifacts/src/lib.rs +++ b/cfgsync/artifacts/src/lib.rs @@ -12,11 +12,8 @@ pub struct ArtifactFile { impl ArtifactFile { #[must_use] - pub fn new(path: impl Into, content: impl Into) -> Self { - Self { - path: path.into(), - content: content.into(), - } + pub fn new(path: String, content: String) -> Self { + Self { path, content } } } diff --git a/cfgsync/core/src/client.rs b/cfgsync/core/src/client.rs index 444e070..52dd22b 100644 --- a/cfgsync/core/src/client.rs +++ b/cfgsync/core/src/client.rs @@ -39,8 +39,8 @@ pub struct Client { impl Client { /// Creates a cfgsync client pointed at the given server base URL. #[must_use] - pub fn new(base_url: impl Into) -> Self { - let mut base_url = base_url.into(); + pub fn new(base_url: String) -> Self { + let mut base_url = base_url; while base_url.ends_with('/') { base_url.pop(); } diff --git a/cfgsync/core/src/protocol.rs b/cfgsync/core/src/protocol.rs index 1bba7d7..2b81ca4 100644 --- a/cfgsync/core/src/protocol.rs +++ b/cfgsync/core/src/protocol.rs @@ -122,9 +122,9 @@ pub struct NodeRegistration { impl NodeRegistration { /// Creates a registration with the generic node identity fields only. #[must_use] - pub fn new(identifier: impl Into, ip: Ipv4Addr) -> Self { + pub fn new(identifier: String, ip: Ipv4Addr) -> Self { Self { - identifier: identifier.into(), + identifier, ip, metadata: RegistrationPayload::default(), } @@ -212,10 +212,10 @@ impl CfgsyncErrorResponse { /// Builds an internal cfgsync error. #[must_use] - pub fn internal(message: impl Into) -> Self { + pub fn internal(message: String) -> Self { Self { code: CfgsyncErrorCode::Internal, - message: message.into(), + message, } } } @@ -251,12 +251,13 @@ mod tests { #[test] fn registration_payload_round_trips_typed_value() { - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")) - .with_metadata(&ExampleRegistration { - network_port: 3000, - service: "blend".to_owned(), - }) - .expect("serialize registration metadata"); + let registration = + NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().expect("parse ip")) + .with_metadata(&ExampleRegistration { + network_port: 3000, + service: "blend".to_owned(), + }) + .expect("serialize registration metadata"); let encoded = serde_json::to_value(®istration).expect("serialize registration"); let metadata = encoded.get("metadata").expect("registration metadata"); diff --git a/cfgsync/core/src/server.rs b/cfgsync/core/src/server.rs index 925e997..1c56f54 100644 --- a/cfgsync/core/src/server.rs +++ b/cfgsync/core/src/server.rs @@ -213,7 +213,10 @@ mod tests { fn sample_payload() -> NodeArtifactsPayload { NodeArtifactsPayload { schema_version: CFGSYNC_SCHEMA_VERSION, - files: vec![NodeArtifactFile::new("/app-config.yaml", "app: test")], + files: vec![NodeArtifactFile::new( + "/app-config.yaml".to_string(), + "app: test".to_string(), + )], } } @@ -227,7 +230,8 @@ mod tests { registrations: std::sync::Mutex::new(HashMap::new()), }); let state = Arc::new(CfgsyncServerState::new(provider)); - let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); + let payload = + NodeRegistration::new("node-a".to_string(), "127.0.0.1".parse().expect("valid ip")); let _ = register_node(State(state.clone()), Json(payload.clone())) .await @@ -246,7 +250,10 @@ mod tests { data: HashMap::new(), }); let state = Arc::new(CfgsyncServerState::new(provider)); - let payload = NodeRegistration::new("missing-node", "127.0.0.1".parse().expect("valid ip")); + let payload = NodeRegistration::new( + "missing-node".to_string(), + "127.0.0.1".parse().expect("valid ip"), + ); let response = node_config(State(state), Json(payload)) .await @@ -272,7 +279,8 @@ mod tests { registrations: std::sync::Mutex::new(HashMap::new()), }); let state = Arc::new(CfgsyncServerState::new(provider)); - let payload = NodeRegistration::new("node-a", "127.0.0.1".parse().expect("valid ip")); + let payload = + NodeRegistration::new("node-a".to_string(), "127.0.0.1".parse().expect("valid ip")); let response = node_config(State(state), Json(payload)) .await diff --git a/cfgsync/core/src/source.rs b/cfgsync/core/src/source.rs index 2cdf513..00a04a3 100644 --- a/cfgsync/core/src/source.rs +++ b/cfgsync/core/src/source.rs @@ -187,7 +187,10 @@ mod tests { }; fn sample_payload() -> NodeArtifactsPayload { - NodeArtifactsPayload::from_files(vec![NodeArtifactFile::new("/config.yaml", "key: value")]) + NodeArtifactsPayload::from_files(vec![NodeArtifactFile::new( + "/config.yaml".to_string(), + "key: value".to_string(), + )]) } #[test] @@ -197,7 +200,7 @@ mod tests { let repo = StaticConfigSource { configs }; match repo.resolve(&NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )) { ConfigResolveResponse::Config(payload) => { @@ -216,7 +219,7 @@ mod tests { }; match repo.resolve(&NodeRegistration::new( - "unknown-node", + "unknown-node".to_string(), "127.0.0.1".parse().expect("parse ip"), )) { ConfigResolveResponse::Config(_) => panic!("expected missing-config error"), @@ -245,12 +248,12 @@ nodes: BundleConfigSource::from_yaml_file(bundle_file.path()).expect("load file provider"); let _ = provider.register(NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )); match provider.resolve(&NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )) { ConfigResolveResponse::Config(payload) => assert_eq!(payload.files.len(), 1), @@ -265,7 +268,7 @@ nodes: let repo = StaticConfigSource { configs }; match repo.resolve(&NodeRegistration::new( - "node-1", + "node-1".to_string(), "127.0.0.1".parse().expect("parse ip"), )) { ConfigResolveResponse::Config(_) => {} diff --git a/cfgsync/runtime/examples/minimal_cfgsync.rs b/cfgsync/runtime/examples/minimal_cfgsync.rs index 38db4f0..11308a6 100644 --- a/cfgsync/runtime/examples/minimal_cfgsync.rs +++ b/cfgsync/runtime/examples/minimal_cfgsync.rs @@ -23,7 +23,7 @@ impl RegistrationSnapshotMaterializer for ExampleMaterializer { ( registration.identifier.clone(), ArtifactSet::new(vec![ArtifactFile::new( - "/config.yaml", + "/config.yaml".to_string(), format!("id: {}\n", registration.identifier), )]), ) @@ -44,8 +44,8 @@ async fn main() -> anyhow::Result<()> { sleep(Duration::from_millis(100)).await; let tempdir = tempdir()?; - let outputs = OutputMap::under(tempdir.path()); - let registration = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + let outputs = OutputMap::under(tempdir.path().to_path_buf()); + let registration = NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse()?); Client::new("http://127.0.0.1:4400") .fetch_and_write(®istration, &outputs) diff --git a/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs b/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs index dc5774b..e10a7b2 100644 --- a/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs +++ b/cfgsync/runtime/examples/precomputed_registration_cfgsync.rs @@ -11,16 +11,22 @@ async fn main() -> anyhow::Result<()> { let artifacts = MaterializedArtifacts::from_nodes([ ( "node-1".to_owned(), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "id: node-1\n")]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + "id: node-1\n".to_string(), + )]), ), ( "node-2".to_owned(), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", "id: node-2\n")]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + "id: node-2\n".to_string(), + )]), ), ]) .with_shared(ArtifactSet::new(vec![ArtifactFile::new( - "/shared/cluster.yaml", - "cluster: demo\n", + "/shared/cluster.yaml".to_string(), + "cluster: demo\n".to_string(), )])); let server = tokio::spawn(async move { serve(port, artifacts).await }); @@ -33,7 +39,7 @@ async fn main() -> anyhow::Result<()> { node_1_dir.path().join("config.yaml"), node_1_dir.path().join("shared"), ); - let node_1 = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + let node_1 = NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse()?); Client::new("http://127.0.0.1:4401") .fetch_and_write(&node_1, &node_1_outputs) @@ -53,7 +59,7 @@ async fn main() -> anyhow::Result<()> { node_2_dir.path().join("config.yaml"), node_2_dir.path().join("shared"), ); - let node_2 = NodeRegistration::new("node-2", "127.0.0.2".parse()?); + let node_2 = NodeRegistration::new("node-2".to_string(), "127.0.0.2".parse()?); Client::new("http://127.0.0.1:4401") .fetch_and_write(&node_2, &node_2_outputs) diff --git a/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs b/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs index 6e25151..d2a2823 100644 --- a/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs +++ b/cfgsync/runtime/examples/wait_for_registrations_cfgsync.rs @@ -23,7 +23,7 @@ impl RegistrationSnapshotMaterializer for ThresholdMaterializer { ( registration.identifier.clone(), ArtifactSet::new(vec![ArtifactFile::new( - "/config.yaml", + "/config.yaml".to_string(), format!("id: {}\ncluster_ready: true\n", registration.identifier), )]), ) @@ -43,8 +43,8 @@ async fn main() -> anyhow::Result<()> { sleep(Duration::from_millis(100)).await; let waiting_dir = tempdir()?; - let waiting_outputs = OutputMap::under(waiting_dir.path()); - let waiting_node = NodeRegistration::new("node-1", "127.0.0.1".parse()?); + let waiting_outputs = OutputMap::under(waiting_dir.path().to_path_buf()); + let waiting_node = NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse()?); let waiting_client = Client::new("http://127.0.0.1:4402"); let waiting_task = tokio::spawn(async move { @@ -58,8 +58,8 @@ async fn main() -> anyhow::Result<()> { sleep(Duration::from_millis(400)).await; let second_dir = tempdir()?; - let second_outputs = OutputMap::under(second_dir.path()); - let second_node = NodeRegistration::new("node-2", "127.0.0.2".parse()?); + let second_outputs = OutputMap::under(second_dir.path().to_path_buf()); + let second_node = NodeRegistration::new("node-2".to_string(), "127.0.0.2".parse()?); Client::new("http://127.0.0.1:4402") .fetch_and_write(&second_node, &second_outputs) diff --git a/cfgsync/runtime/src/client.rs b/cfgsync/runtime/src/client.rs index 3cd8091..f2372bd 100644 --- a/cfgsync/runtime/src/client.rs +++ b/cfgsync/runtime/src/client.rs @@ -39,12 +39,8 @@ impl OutputMap { /// Routes one artifact path from the payload to a local output path. #[must_use] - pub fn route( - mut self, - artifact_path: impl Into, - output_path: impl Into, - ) -> Self { - self.routes.insert(artifact_path.into(), output_path.into()); + pub fn route(mut self, artifact_path: String, output_path: PathBuf) -> Self { + self.routes.insert(artifact_path, output_path); self } @@ -54,26 +50,20 @@ impl OutputMap { /// `shared/deployment-settings.yaml` is written to /// `/shared/deployment-settings.yaml`. #[must_use] - pub fn under(root: impl Into) -> Self { + pub fn under(root: PathBuf) -> Self { Self { routes: HashMap::new(), - fallback: Some(FallbackRoute::Under(root.into())), + fallback: Some(FallbackRoute::Under(root)), } } /// Writes the node config to `config_path` and all other files under /// `shared_dir`, preserving their relative artifact paths. #[must_use] - pub fn config_and_shared( - config_path: impl Into, - shared_dir: impl Into, - ) -> Self { - let config_path = config_path.into(); - let shared_dir = shared_dir.into(); - + pub fn config_and_shared(config_path: PathBuf, shared_dir: PathBuf) -> Self { Self::default() - .route("/config.yaml", config_path.clone()) - .route("config.yaml", config_path) + .route("/config.yaml".to_string(), config_path.clone()) + .route("config.yaml".to_string(), config_path) .with_fallback(FallbackRoute::Shared { dir: shared_dir }) } @@ -119,7 +109,7 @@ impl Client { #[must_use] pub fn new(server_addr: &str) -> Self { Self { - inner: ProtocolClient::new(server_addr), + inner: ProtocolClient::new(server_addr.to_string()), } } @@ -306,16 +296,20 @@ fn build_output_map() -> OutputMap { let mut outputs = OutputMap::default(); if let Ok(path) = env::var("CFG_FILE_PATH") { + let path = PathBuf::from(path); + outputs = outputs - .route("/config.yaml", path.clone()) - .route("config.yaml", path); + .route("/config.yaml".to_string(), path.clone()) + .route("config.yaml".to_string(), path); } if let Ok(path) = env::var("CFG_DEPLOYMENT_PATH") { + let path = PathBuf::from(path); + outputs = outputs - .route("/deployment.yaml", path.clone()) - .route("deployment-settings.yaml", path.clone()) - .route("/deployment-settings.yaml", path); + .route("/deployment.yaml".to_string(), path.clone()) + .route("deployment-settings.yaml".to_string(), path.clone()) + .route("/deployment-settings.yaml".to_string(), path); } outputs @@ -339,8 +333,14 @@ mod tests { let bundle = NodeArtifactsBundle::new(vec![NodeArtifactsBundleEntry { identifier: "node-1".to_owned(), files: vec![ - NodeArtifactFile::new(app_config_path.to_string_lossy(), "app_key: app_value"), - NodeArtifactFile::new(deployment_path.to_string_lossy(), "mode: local"), + NodeArtifactFile::new( + app_config_path.to_string_lossy().into_owned(), + "app_key: app_value".to_string(), + ), + NodeArtifactFile::new( + deployment_path.to_string_lossy().into_owned(), + "mode: local".to_string(), + ), ], }]); @@ -356,7 +356,10 @@ mod tests { Client::new(&address) .fetch_and_write( - &NodeRegistration::new("node-1", "127.0.0.1".parse().expect("parse ip")), + &NodeRegistration::new( + "node-1".to_string(), + "127.0.0.1".parse().expect("parse ip"), + ), &OutputMap::default(), ) .await diff --git a/cfgsync/runtime/src/server.rs b/cfgsync/runtime/src/server.rs index eb33704..25b54bf 100644 --- a/cfgsync/runtime/src/server.rs +++ b/cfgsync/runtime/src/server.rs @@ -76,24 +76,20 @@ impl ServerConfig { } #[must_use] - pub fn for_static(port: u16, artifacts_path: impl Into) -> Self { + pub fn for_static(port: u16, artifacts_path: String) -> Self { Self { port, - source: ServerSource::Static { - artifacts_path: artifacts_path.into(), - }, + source: ServerSource::Static { artifacts_path }, } } /// Builds a config that serves precomputed artifacts through the /// registration flow. #[must_use] - pub fn for_registration(port: u16, artifacts_path: impl Into) -> Self { + pub fn for_registration(port: u16, artifacts_path: String) -> Self { Self { port, - source: ServerSource::Registration { - artifacts_path: artifacts_path.into(), - }, + source: ServerSource::Registration { artifacts_path }, } } } diff --git a/logos/runtime/ext/src/cfgsync/mod.rs b/logos/runtime/ext/src/cfgsync/mod.rs index 5f05fb7..268d393 100644 --- a/logos/runtime/ext/src/cfgsync/mod.rs +++ b/logos/runtime/ext/src/cfgsync/mod.rs @@ -85,7 +85,7 @@ fn config_file_content(artifacts: &cfgsync_artifacts::ArtifactSet) -> Option ArtifactFile { - ArtifactFile::new(path, content) + ArtifactFile::new(path.to_string(), content.to_string()) } fn extract_yaml_key(content: &str, key: &str) -> Result { diff --git a/testing-framework/core/src/cfgsync/mod.rs b/testing-framework/core/src/cfgsync/mod.rs index e48642c..2f93d7b 100644 --- a/testing-framework/core/src/cfgsync/mod.rs +++ b/testing-framework/core/src/cfgsync/mod.rs @@ -79,7 +79,10 @@ pub fn build_static_artifacts( output.insert( E::node_identifier(index, node), - ArtifactSet::new(vec![ArtifactFile::new("/config.yaml", &config_yaml)]), + ArtifactSet::new(vec![ArtifactFile::new( + "/config.yaml".to_string(), + config_yaml.clone(), + )]), ); }