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:
- Connect, shake hands, and read the ref advertisement. This is what
git ls-remotecosts: a few hundred bytes, and no objects. - 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.
- Otherwise ask for that one commit at depth 1 — the commit and its trees and blobs, and none of the history behind it.
- 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:
| Moves | Reproducible | For | |
|---|---|---|---|
branch — the default, main | yes | no | hot reload: a merge to main is the deployment |
tag | only if force-pushed | nearly | a release train |
commit | never | yes | pinning 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
GitSourceneeds, 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.