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