1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
//! `Control`'s reload surface (ADR 000007 / 000008): atomically swap to a new manifest, or
//! re-read the on-disk manifest and reload if its `config version` changed. Split out from
//! `lib.rs` since this is the crate's most correctness-sensitive method group (trust-change
//! rejection, all-or-nothing swap) — its own file matches "where's the thing that does X".
use std::sync::Arc;
use crate::error::ControlError;
use crate::manifest::Manifest;
use crate::reload::ReloadOutcome;
use crate::{Control, build_active, read_manifest};
impl Control {
/// Atomically swap to a new manifest's filter set + chain (ADR 000007: build the new set
/// fully, then switch in one store; the old set is drained as its `Arc` refs drop). If any
/// filter fails to resolve / verify / load, the swap does **not** happen and the current
/// set stays live — reload is all-or-nothing. The trust policy is fixed at construction.
pub fn reload(&self, manifest: &Manifest) -> Result<(), ControlError> {
let _gate = self.reload_gate.lock();
self.ensure_trust_unchanged(manifest)?;
self.ensure_state_unchanged(manifest)?;
let active = build_active(
&self.host,
manifest,
self.store.as_ref(),
&self.base_dir,
&self.upstreams,
)?;
self.active.store(Arc::new(active));
Ok(())
}
/// Reject a reload whose manifest changes the `[trust]` section (f000004 #1). Trust roots
/// are fixed for the life of the `Host` / epoch ticker; an operator rotates them by
/// restarting with the new manifest, not by reloading — otherwise a trust-only edit would
/// flip the content hash and be reported as a successful reload while having no effect.
pub(crate) fn ensure_trust_unchanged(&self, manifest: &Manifest) -> Result<(), ControlError> {
if manifest.trust != self.trust {
return Err(ControlError::TrustChangeRequiresRestart);
}
Ok(())
}
/// Reject a reload whose manifest changes the `[state]` section (ADR 000041). The state
/// backend is fixed for the life of the `Host` — same contract, same reasoning as
/// `ensure_trust_unchanged`: a silently-dropped backend/path edit would read as a
/// successful durability change that never happened.
pub(crate) fn ensure_state_unchanged(&self, manifest: &Manifest) -> Result<(), ControlError> {
if manifest.state != self.state {
return Err(ControlError::StateChangeRequiresRestart);
}
Ok(())
}
/// Re-read the on-disk manifest and reload if its `config version` changed. The trigger
/// (SIGHUP, `serve_reloads`) is content-free, so this is where the new config is actually
/// read. Idempotent: an unchanged manifest (same semantic `content_hash`) is a no-op —
/// no rebuild, no drain. A changed one is built fully and swapped atomically; on any
/// build failure the running set is left untouched (fail-closed) and the error returned.
///
/// Errors with `NoManifestPath` if this plane was not built from an on-disk manifest
/// (`load` / `from_manifest`); use `from_manifest_path` / `load_at` for a reloadable plane.
pub fn reload_from_disk(&self) -> Result<ReloadOutcome, ControlError> {
let _gate = self.reload_gate.lock();
let path = self
.manifest_path
.as_ref()
.ok_or(ControlError::NoManifestPath)?;
let manifest = read_manifest(path)?;
// A [trust] or [state] change is rejected before anything else: it must never be
// reported as a successful reload (f000004 #1 / ADR 000041), even though it would flip
// the content hash below.
self.ensure_trust_unchanged(&manifest)?;
self.ensure_state_unchanged(&manifest)?;
// Cheap idempotency gate: skip the rebuild + drain entirely when the config version is
// unchanged (a comment-only edit, or a spurious trigger). A version that cannot be
// computed (the client-auth CA momentarily unreadable, e.g. mid-rotation) falls through
// to the full build instead of failing a possibly-idempotent SIGHUP outright — the build
// re-reads the CA and fails closed with the precise error if the problem persists.
match manifest.content_hash_at(Some(&self.base_dir)) {
Ok(new_hash) if new_hash == self.active.load().hash => {
// Deliberate ADR 000014 sharp edge for paths that are NOT file-digested yet
// (e.g. [[tls]] cert/key in-place renewals): an unchanged version does not
// re-read those files. `[listen.client_auth].ca_path` bytes ARE mixed into the
// version, so in-place CA rotation does flip and rebuild.
tracing::info!(
config_version = %new_hash,
"reload: config version unchanged — no rebuild; note that referenced files \
without a content digest in the version (TLS certs/keys) are not re-read \
on an unchanged version (ADR 000014); client_auth CA bytes are digested"
);
return Ok(ReloadOutcome::Unchanged);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(
error = %e,
"reload: config version unavailable — attempting the full rebuild"
);
}
}
// Build the new set fully before swapping; on failure the running set is untouched. The
// outcome carries the hash the build computed (from the SAME CA read as its verifier),
// not the gate's — the two could differ if the CA file changed in between.
let active = build_active(
&self.host,
&manifest,
self.store.as_ref(),
&self.base_dir,
&self.upstreams,
)?;
let hash = active.hash.clone();
self.active.store(Arc::new(active));
Ok(ReloadOutcome::Reloaded { hash })
}
/// The active config's `content_hash` (ADR 000008 `config version`): the audit identity of
/// what is loaded right now, and the unit a future opt-in consensus layer would agree on.
pub fn config_version(&self) -> String {
self.active.load().hash.clone()
}
}