# 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: - `cfgsync` owns 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: - `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` - `Client` - `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(...)` 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. ## Start here Start with the examples in `cfgsync/runtime/examples/`. - `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. 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: 1. define a typed registration payload 2. implement `RegistrationSnapshotMaterializer` 3. return node-local and optional shared artifacts 4. serve them with `serve(...)` 5. use `Client` on the node side That gives you the main value of the library without pushing application logic into cfgsync itself. ## API sketch Typed registration payload: ```rust use cfgsync_core::NodeRegistration; #[derive(serde::Serialize)] struct MyNodeMetadata { network_port: u16, api_port: u16, } let registration = NodeRegistration::new("node-1".to_string(), "127.0.0.1".parse().unwrap()) .with_metadata(&MyNodeMetadata { network_port: 3000, api_port: 18080, })?; ``` Snapshot materializer: ```rust 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 { 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: ```rust use cfgsync_runtime::serve; # async fn run() -> anyhow::Result<()> { serve(4400, MyMaterializer).await?; # Ok(()) # } ``` Fetching and writing artifacts: ```rust 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.