Skip to main content

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}