266 lines
8.2 KiB
Markdown
Raw Normal View History

# 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:
2026-03-12 07:55:08 +01:00
- `cfgsync` owns registration, polling, payload transport, and file delivery.
- the application adapter owns readiness policy and artifact generation.
2026-03-12 07:55:08 +01:00
The result is a reusable library without application-specific bootstrap logic leaking into core crates.
2026-03-12 07:55:08 +01:00
## How it works
2026-03-12 07:55:08 +01:00
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`
2026-03-12 10:07:22 +01:00
- `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 adapters job is simple: given the current registration snapshot, decide whether artifacts are ready, and if they are, return them.
2026-03-12 07:55:08 +01:00
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
2026-03-12 09:51:03 +01:00
- 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.
2026-03-12 07:55:08 +01:00
## Artifact model
The adapter usually thinks in full snapshots, but cfgsync serves one node at a time.
The materializer returns `MaterializedArtifacts`, which contain:
2026-03-12 07:55:08 +01:00
- node-local artifacts keyed by node identifier
- optional shared artifacts
When one node fetches config, cfgsync resolves that nodes 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.
2026-03-12 07:55:08 +01:00
In practice, the adapter should define:
2026-03-12 07:55:08 +01:00
- 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.
2026-03-12 10:00:10 +01:00
## Start here
2026-03-12 10:03:02 +01:00
Start with the examples in `cfgsync/runtime/examples/`.
2026-03-12 10:00:10 +01:00
2026-03-12 10:03:02 +01:00
- `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.
2026-03-12 10:00:10 +01:00
2026-03-12 10:03:02 +01:00
Those three examples cover the full public model. The rest of this README just
names the pieces and explains where application-specific logic belongs.
2026-03-12 10:00:10 +01:00
## 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
2026-03-12 09:51:03 +01:00
4. serve them with `serve(...)`
2026-03-12 10:03:02 +01:00
5. use `Client` on the node side
2026-03-12 10:03:02 +01:00
That gives you the main value of the library without pushing application logic
into cfgsync itself.
2026-03-12 07:55:08 +01:00
2026-03-12 10:03:02 +01:00
## 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::{
2026-03-12 07:44:20 +01:00
DynCfgsyncError, MaterializationResult, MaterializedArtifacts, RegistrationSnapshot,
RegistrationSnapshotMaterializer,
};
2026-03-12 07:44:20 +01:00
use cfgsync_artifacts::{ArtifactFile, ArtifactSet};
struct MyMaterializer;
impl RegistrationSnapshotMaterializer for MyMaterializer {
fn materialize_snapshot(
&self,
registrations: &RegistrationSnapshot,
2026-03-12 07:44:20 +01:00
) -> Result<MaterializationResult, DynCfgsyncError> {
if registrations.len() < 2 {
2026-03-12 07:44:20 +01:00
return Ok(MaterializationResult::NotReady);
}
2026-03-12 07:55:08 +01:00
let nodes = registrations.iter().map(|registration| {
(
2026-03-12 07:44:20 +01:00
registration.identifier.clone(),
ArtifactSet::new(vec![ArtifactFile::new(
"/config.yaml",
format!("id: {}\n", registration.identifier),
2026-03-12 07:44:20 +01:00
)]),
2026-03-12 07:55:08 +01:00
)
});
2026-03-12 07:44:20 +01:00
Ok(MaterializationResult::ready(
MaterializedArtifacts::from_nodes(nodes),
))
}
}
```
Serving:
```rust
2026-03-12 09:51:03 +01:00
use cfgsync_runtime::serve;
# async fn run() -> anyhow::Result<()> {
2026-03-12 09:51:03 +01:00
serve(4400, MyMaterializer).await?;
# Ok(())
# }
```
Fetching and writing artifacts:
```rust
use cfgsync_runtime::{Client, OutputMap};
# async fn run(registration: cfgsync_core::NodeRegistration) -> anyhow::Result<()> {
2026-03-12 10:00:10 +01:00
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(&registration, &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.