8.2 KiB
cfgsync
cfgsync is a small library stack for bootstrap-time config delivery.
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.
That split is the point of the design:
cfgsyncowns registration, polling, payload transport, and file delivery.- the application adapter owns readiness policy and artifact generation.
The result is a reusable library without application-specific bootstrap logic leaking into core crates.
How it works
The normal flow is registration-backed serving.
Each node first sends a registration containing:
- a stable node identifier
- its IP address
- optional typed application metadata
The server stores registrations and builds a RegistrationSnapshot. The application provides a RegistrationSnapshotMaterializer, which receives that snapshot and decides whether configuration is ready yet.
If the materializer returns NotReady, the node keeps polling. If it returns Ready, cfgsync serves one payload containing:
- node-local files for the requesting node
- optional shared files that every node should receive
The node then writes those files locally and continues startup.
That is the main model. Everything else is a variation of it.
Precomputed artifacts
Some systems already know the final artifacts before any node starts. That still fits the same model.
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.
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:
ArtifactFilefor a single fileArtifactSetfor 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:
NodeRegistrationRegistrationPayloadNodeArtifactsPayloadCfgsyncClientNodeConfigSource
It also defines the HTTP contract:
POST /registerPOST /node
The server answers with either a payload, NotReady, or Missing.
cfgsync-adapter
This crate defines the application-facing seam.
The key types are:
RegistrationSnapshotRegistrationSnapshotMaterializerMaterializedArtifactsMaterializationResult
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:
CachedSnapshotMaterializerPersistingSnapshotMaterializerRegistrationConfigSource
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(...)
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
ninitial 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.
Start here
Start with the examples in cfgsync/runtime/examples/.
minimal_cfgsync.rsshows the smallest complete flow: serve cfgsync, register one node, fetch artifacts, and write them locally.precomputed_registration_cfgsync.rsshows 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.rsshows the normalNotReadypath: one node waits until the materializer sees enough registrations, then both nodes receive config.
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
For a new application, the shortest sensible path is:
- define a typed registration payload
- implement
RegistrationSnapshotMaterializer - return node-local and optional shared artifacts
- serve them with
serve(...) - use
Clienton the node side
That gives you the main value of the library without pushing application logic into cfgsync itself.
API sketch
Typed registration payload:
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,
})?;
Snapshot materializer:
use cfgsync_adapter::{
DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot,
RegistrationSnapshotMaterializer,
};
use cfgsync_artifacts::{ArtifactFile, ArtifactSet};
struct MyMaterializer;
impl RegistrationSnapshotMaterializer for MyMaterializer {
fn materialize_snapshot(
&self,
registrations: &RegistrationSnapshot,
) -> Result<MaterializationResult, DynCfgsyncError> {
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: {}\n", registration.identifier),
)]),
)
});
Ok(MaterializationResult::ready(
MaterializedArtifacts::from_nodes(nodes),
))
}
}
Serving:
use cfgsync_runtime::serve;
# async fn run() -> anyhow::Result<()> {
serve(4400, MyMaterializer).await?;
# Ok(())
# }
Fetching and writing artifacts:
use cfgsync_runtime::{Client, OutputMap};
# async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> {
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)
.await?;
# Ok(())
# }
Compatibility
The intended public API is what the crate roots reexport today.
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.