Skip to main content

Crate dynamic_config_git

Crate dynamic_config_git 

Source
Expand description

Read dynamic-config configuration from a git repository.

Configuration in git is how a great many teams already work: review, history, blame and rollback come free, and nobody runs etcd for a file that changes twice a month. This crate reads a file — or a set of them, out of one commit — at one ref, from one repository, and hands it to dynamic-config the way every other store crate does.

use dynamic_config_git::{Credential, GitSource};

let source = GitSource::builder("https://github.com/acme/config.git")
    .branch("main")
    .path("services/api/config.yaml")
    .credential(Credential::token(std::env::var("GITHUB_TOKEN")?))
    .build()?;

AppConfig::set_remote(source);

// Fetching is explicit; the load that follows touches no network.
AppConfig::refresh_remote()?;
AppConfig::builder("app").init()?;

§Why git rather than four REST APIs

GitHub, GitLab, Azure DevOps, Gitea, Bitbucket and a bare git@host:repo.git all speak git. Their file APIs are five clients, five auth models, five pagination stories and five ways of spelling this ref. “Compatible with all of them” is only reachable through the protocol they share, and the extra round trip that protocol costs is irrelevant at configuration cadence.

The implementation is gix — pure Rust, no libgit2, no C toolchain and no OpenSSL question. The exception is SSH, which gix carries by spawning the system ssh exactly as git does; see SshAuth.

§What a fetch actually does

A shallow, single-ref fetch into a bare object database — never a clone. In order:

  1. Connect, shake hands, and read the ref advertisement. This is what git ls-remote costs: a few hundred bytes, and no objects.
  2. If the commit the ref names is already in the object database, stop. An unchanged ref transfers nothing. That is what makes polling a git host reasonable.
  3. Otherwise ask for that one commit at depth 1 — the commit and its trees and blobs, and none of the history behind it.
  4. Read one blob out of the tree, in memory. Nothing is ever checked out.

What it costs: the first fetch transfers the repository’s current tree — every file at that commit, not just the one asked for, because a commit’s tree is what the protocol delivers. A monorepo whose tree is a gigabyte will transfer a gigabyte once. Subsequent fetches transfer one commit’s worth of changes.

Filtering by path would cut that first transfer to the files actually read, and it is not implemented because nothing below this crate can express it, which is worth being precise about rather than calling it a to-do. gix 0.86 exposes no filter on a fetch: the protocol argument exists one layer down in gix-protocol, on a type only gix’s own fetch ever holds. And the filter the large hosts actually serve is blob:none, which answers with a tree whose blobs are absent — reading one then means a lazy fetch from a promisor remote, which nothing in this dependency graph implements. A path filter is therefore two upstream features away, not one call. The honest summary is that this crate is comfortable with a configuration repository and will be slow to start against a monorepo.

There is no working tree, and that is the security decision as much as the performance one: a repository whose tree contains a symlink to /etc/shadow, or an entry named ../../etc/shadow, cannot make a checkout that never happens write anywhere. See Builder::path.

§Which ref, and why a branch is the default

Reference is a branch, a tag or a commit SHA, and all three are legitimate:

MovesReproducibleFor
branch — the default, mainyesnohot reload: a merge to main is the deployment
tagonly if force-pushednearlya release train
commitneveryespinning a fleet to a known configuration

A branch is the default because a configuration store’s reason to exist is that the configuration changes: pinning a SHA and then starting a watcher is asking a loop to wait for something that cannot happen. Pin the SHA when reproducibility matters more than reload, and say so by writing it down.

A SHA is fetched by asking the host for that object directly. Hosts that allow it — GitHub, GitLab and Azure DevOps do — answer; one that has uploadpack.allowReachableSHA1InWant off will refuse, and the error says so.

§Where the objects live

A private directory, 0700 from the moment it exists. By default a temporary one, removed with the source; name your own with cache_dir to survive restarts. The trade-offs, and why two sources may not share one, are in working.

§When a fetch fails

It does not take the program down. A failed RemoteSource::fetch leaves the previously fetched document installed and the previously loaded configuration serving — that is dynamic-config’s last-known-good machinery, and this crate’s only job is to report accurately enough for it to work:

  • a host that refuses the credential is ErrorKind::Auth, because waiting will not fix a wrong token and a watch loop should stop rather than hammer;
  • everything else — an unreachable host, a ref that does not exist, a document that is not UTF-8 — is ErrorKind::Remote, which a watch loop waits out.

§Credentials never appear in a diagnostic

A git remote URL routinely embeds one. Every error message, every Debug and every string this crate produces puts the URL through dynamic_config_store_core::redacted first, and the tests plant a token and assert it is absent. An SSH key’s contents are never read by this crate at all, and a passphrase is never accepted — see auth for why.

§Watching

git has no watch, so GitSource::watch polls — and says so. Each tick is one ref advertisement; only a ref that moved costs a transfer. The push half needs nothing from this crate: whoever terminates a GitHub or GitLab webhook calls the generated remote_sink().apply(..).

let watch = RemoteWatch::new();
let watching = watch.watching();

std::thread::spawn(move || {
    source.watch(&watching, Duration::from_secs(60), move |document| sink.apply(document))
});

// Dropping `watch` — or calling `watch.stop()` — ends the loop.

§Several files as one document

One repository, one ref, and either one path, a list of them or a directory — see Keys. A fetch resolves one commit, and a commit has one tree, so a set of files is read as of one instant with nothing arranged for it: no transaction, no listing race, no second round trip. That is why this is the only store in the family whose multi-file sources can also be watched; the others refuse, and say why.

§A host this machine does not already trust

An enterprise GitLab behind a private certificate authority, or a host that wants a client certificate first, is Builder::tls. It is the https:// knob and only that one — an ssh:// remote’s trust lives in known_hosts and its client identity in a key, which is Credential::ssh_agent, Credential::ssh_key or Credential::ssh_command, and asking for both is refused rather than half-applied. There is no way to turn verification off; tls has the measurement and the argument.

§What this crate deliberately does not do

An async implementation. A git fetch is blocking work — negotiation, decompression, index writing — so this implements the blocking RemoteSource. An async program loses nothing: refresh_remote_async() puts a blocking source on dynamic_config::off_thread, so the executor’s worker never sits inside it.

Shelling out to the system git. It would reach every credential helper on the host for free, and it would also be a second implementation of every decision on this page — the fetch shape, the ref pinning, the error classification, and the redaction of a URL that git prints into its own stderr. SshAuth::Command reaches the one method the pure-Rust path cannot, and does it without a second code path.

Re-exports§

pub use auth::Auth;
pub use auth::Credential;
pub use auth::SshAuth;

Modules§

auth
What to present to a git host, and where it comes from.
tls
HTTPS to a host this machine does not already trust.
working
Where the objects live between fetches, and who may read them.

Structs§

Builder
Collects what a GitSource needs, and refuses what it cannot use.
GitSource
A file in a git repository, as a configuration source.
TlsConfig
What a store needs to speak TLS to somewhere this machine does not already trust.

Enums§

Keys
What a source reads: one file, several named ones, or a directory.
Reference
Which commit to read.