Skip to main content

acme_proxy_server/
reload.rs

1//! Replacing a running configuration without restarting the process.
2//!
3//! `serve_on_with` builds everything exactly once — profiles, deduplicated
4//! signer backends, filter chains, challenge registries, both routers — so until
5//! this module existed, every configuration change was a process restart. For
6//! the keys operators actually touch (a `[filter]` rule, an `[ipam]` token, a
7//! `[notify]` webhook, a renewed certificate) that dropped in-flight ACME orders
8//! and every live connection for a change that never needed a new socket.
9//!
10//! A reload is a **rebuild and swap**, not a mutation. Everything is constructed
11//! and validated first; only once all of it succeeded is anything published. The
12//! publishing itself goes through [`tokio::sync::watch`] cells, and that choice
13//! is load-bearing rather than stylistic: `watch::Sender::send_replace` is
14//! *synchronous*, so a run of sends with no `.await` between them cannot be
15//! observed half-applied — no other task can run in the middle of it.
16//!
17//! What a swap cannot honour is refused **by name**, and the whole reload is
18//! refused with it. Exactly one key is left in that category — `database.url`,
19//! the pool being open and the accounts and orders issued against it not
20//! following a URL elsewhere. Refusing by name is the posture startup already
21//! takes when it rejects an unknown `logging.target` rather than falling back.
22//!
23//! Everything else that used to be on that list came off the same way — the
24//! thing said to be unmovable was made movable rather than argued with:
25//!
26//! - `[logging]`, the tracing subscriber being installed once per process.
27//!   `server::logging` now installs the whole stack behind a
28//!   `tracing_subscriber::reload::Layer`, so all six keys swap with everything
29//!   else. The publishing run does it **first**, since an operator who raised
30//!   the level did it to see what happens next — starting with the reload's own
31//!   line.
32//! - **The sockets.** `acme_proxy_net::listener` owns the accept loop, so a role's
33//!   `TcpListener` is replaceable and its TLS mode is read per connection: all
34//!   five of `server.bind_address`, `admin.enabled`, `admin.bind_address` and
35//!   both `tls.enabled` flips reload, as do the two `[metrics]` keys. Binding
36//!   happens while a failure can still refuse the reload, so a bad address is
37//!   answered by a socket that never moved.
38//! - **`[jobs]`**, the runner having snapshotted its pacing at spawn. It now
39//!   re-derives that pacing from a `watch` cell on every pass of its loop and
40//!   resizes its own concurrency pool, and the queue reads `max_attempts` from a
41//!   shared atomic — so all seven keys reload, and none of them was ever
42//!   *physically* frozen the way the pool is. These are the knobs an operator
43//!   reaches for mid-incident (slow a retry storm, widen a lease, raise
44//!   concurrency), which made them the worst possible thing to charge a restart
45//!   for. See [`acme_proxy_jobs::jobs::runner`].
46//! - **The profile set, each profile's `[signer]`, and `[dns]`/`[proxy]`** — the
47//!   last four and the hardest, because a signer backend used to own state with
48//!   no durable home: a `LocalCa`'s revocation ledger and a relay's `http-01`
49//!   token store. Both now live in the database, which the outgoing and the
50//!   incoming backend share, so a backend whose configuration moved is simply
51//!   rebuilt — a revocation landing mid-reload is in the table the new instance
52//!   reads, and a challenge fetch in flight is answered from the same rows. A
53//!   backend whose configuration did not move is **reused verbatim** rather
54//!   than rebuilt. Mounting and unmounting an endpoint fell out of it for free,
55//!   that having been the whole of what made the profile set unmovable, and
56//!   `[dns]`/`[proxy]` fell out too: they were frozen only because the signers
57//!   cached them at construction, which is now a reason to *rebuild* a signer
58//!   (they are part of its identity key) rather than to refuse the edit.
59
60use std::convert::Infallible;
61use std::task::{Context, Poll};
62use std::time::Duration;
63
64use axum::Router;
65use axum::body::Body;
66use axum::extract::Request;
67use axum::response::Response;
68use axum::routing::RouterIntoService;
69use tokio::sync::{mpsc, oneshot, watch};
70use tower::Service;
71
72use acme_proxy_core::config::Config;
73use acme_proxy_core::config::ProfileConfig;
74
75/// One resolved configuration, as the frozen-key check sees it.
76///
77/// Two fields rather than one because the per-profile view is not derivable
78/// from a `Config` without work that can fail: `resolve_profiles` overlays the
79/// global sections onto each profile key by key and returns a `Result`. Pairing
80/// them means a projection below is an infallible read.
81///
82/// `profiles` has no reader in [`FROZEN`] any more — the two entries that used
83/// it both came off when the profile set and each profile's `[signer]` became
84/// reloadable. It stays because the pairing is the *shape* of a resolved
85/// configuration and the next entry to be added may well need it, and because
86/// the alternative is a caller assembling the pair again the moment one does.
87pub struct Applied<'a> {
88    pub config: &'a Config,
89    pub profiles: &'a [ProfileConfig],
90}
91
92/// The keys a running process cannot change, and how to read each one.
93///
94/// A projection to `String` rather than `PartialEq` on the config types: nothing
95/// in `crates/core/src/config/` derives it, and `Debug` is already this crate's config
96/// identity primitive (`signer::build_backends` keys its dedup on
97/// `format!("{cfg:?}")`). The projection form is what lets a refusal **name the
98/// key** — a whole-section comparison could only say "server changed".
99///
100/// There is **one entry left**, and it is the only one that was ever *physically*
101/// frozen: the connection pool is open, and the accounts and orders issued
102/// against it do not follow the URL somewhere else. A different database is a
103/// different CA, so this one should stay here for good.
104///
105/// What has come *off* this list is now worth more than what is on it, because
106/// the pattern never varied: the thing said to be unmovable was made movable
107/// rather than argued with, and the entry then had no reason left.
108///
109/// - **Every bind address**, once [`acme_proxy_net::listener`] owned the accept loop and
110///   a socket stopped being something `axum::serve` consumes.
111/// - **`[logging]`**, once the whole layer stack went behind a `reload::Layer`
112///   handle (`server::logging`).
113/// - **All seven `[jobs]` keys**, once the runner stopped snapshotting its
114///   pacing at spawn ([`acme_proxy_jobs::jobs::runner`]).
115/// - **`profiles`, `profiles.*.signer`, `dns.resolver` and `proxy`** — the last
116///   four, and the ones this table existed for. They were frozen *by ownership*:
117///   a signer backend held in-memory state with no durable home, so two
118///   generations over one set of files would disagree, and `[dns]`/`[proxy]`
119///   followed because the signers were the one outbound client never rebuilt.
120///   What ended it was that state moving into the database — a backend whose
121///   configuration did not move is reused verbatim, and one whose configuration
122///   did is rebuilt over the same revocations and tokens the outgoing one
123///   wrote. Mounting and unmounting an endpoint fell out of the same change,
124///   since building or dropping a backend was the whole of what made the
125///   profile set unmovable.
126///
127/// The `[signer]` section is also why this table used to render two of its
128/// entries through a digest: both reached a credential (a proxy URL's
129/// `user:password@`, the HSM PIN, the RFC 2136 TSIG key, the upstream EAB
130/// secret), and [`ReloadError::Frozen`] embeds both renderings in a message
131/// `server::generation::publish_reload`'s caller logs. Nothing left here can
132/// hold one, so the digest is gone with them. **The rule survives the code**:
133/// were an entry ever added back, a whole-section projection must be opaque iff
134/// any field it reaches can hold a credential.
135///
136/// The one entry left now reaches one too. `database.url` was a path with no
137/// password for as long as SQLite was the only backend; a PostgreSQL DSN
138/// carries `user:password@`, so it is projected through
139/// `logfields::redact_url`. It is redacted rather than digested because the
140/// point of this message is to tell an operator *which* value they may not
141/// change, and a digest of a connection string says nothing they can act on.
142type Projection = fn(&Applied<'_>) -> String;
143const FROZEN: &[(&str, Projection)] = &[("database.url", |a| {
144    acme_proxy_core::logfields::redact_url(&a.config.database.url).into_owned()
145})];
146
147/// Why a reload did not happen.
148#[derive(Debug, thiserror::Error)]
149pub enum ReloadError {
150    /// A key a running process cannot change was changed. The whole reload is
151    /// refused: a generation that applied half a file could not be printed back
152    /// faithfully, so "what is this server running?" would stop having an
153    /// answer.
154    #[error(
155        "`{key}` cannot be changed while the server is running \
156         (running with `{applied}`, the file now says `{proposed}`): \
157         restart to apply it"
158    )]
159    Frozen {
160        key: String,
161        applied: String,
162        proposed: String,
163    },
164    /// The new configuration could not be read or resolved.
165    #[error("the configuration did not load: {0}")]
166    Load(String),
167    /// It read, but something it asks for could not be built.
168    #[error("the new configuration did not build: {0}")]
169    Build(String),
170}
171
172impl ReloadError {
173    /// The short tag a log line carries, so an operator can tell a refusal from
174    /// a failure without parsing the message.
175    #[must_use]
176    pub fn kind(&self) -> &'static str {
177        match self {
178            Self::Frozen { .. } => "frozen_key",
179            Self::Load(_) => "load_failed",
180            Self::Build(_) => "build_failed",
181        }
182    }
183}
184
185/// Refuses `proposed` by name if it changes anything [`FROZEN`] covers.
186///
187/// The first mismatch wins and stops the scan: a reload is all-or-nothing, so
188/// listing every offending key would be reporting on a configuration that is
189/// never going to be applied.
190pub fn check_frozen(applied: &Applied<'_>, proposed: &Applied<'_>) -> Result<(), ReloadError> {
191    for (key, read) in FROZEN {
192        let (before, after) = (read(applied), read(proposed));
193        if before != after {
194            return Err(ReloadError::Frozen {
195                key: (*key).to_string(),
196                applied: before,
197                proposed: after,
198            });
199        }
200    }
201    Ok(())
202}
203
204/// What a completed reload did.
205#[derive(Debug, Clone)]
206pub struct ReloadReport {
207    /// Monotonic, starting at 1 for the configuration the process started with.
208    /// The highest-value field in here: it is what a test waits on and what an
209    /// operator greps to answer "did my SIGHUP land?".
210    pub generation: u64,
211    pub profiles: Vec<String>,
212    pub job_kinds: Vec<&'static str>,
213    /// Whether that listener is speaking TLS **after** this reload — not
214    /// whether its certificate changed. A generation always rebuilds both
215    /// acceptors, so "reloaded" was already the wrong word for it; now that
216    /// `tls.enabled` can flip mid-run it is also the answer an operator is
217    /// actually asking for.
218    pub tls_reloaded: bool,
219    pub admin_tls_reloaded: bool,
220    /// The listeners whose socket this reload moved — `acme`, `admin`,
221    /// `metrics` — empty when every one of them stayed where it was, which is
222    /// the ordinary case.
223    pub listeners_rebound: Vec<&'static str>,
224    /// Whether `[logging]` was swapped, which is **not** the same as whether it
225    /// changed: `false` means this process installed no subscriber of its own,
226    /// so there was no handle to swap and logging stayed whatever its owner set
227    /// it to. Reported rather than assumed, since a silent no-op there is the
228    /// one outcome an operator would misread as success.
229    pub logging_reloaded: bool,
230    pub duration: Duration,
231}
232
233/// One request to reload, and where to send the answer.
234///
235/// The responder is optional because the signal path has nobody to answer: a
236/// `SIGHUP` reports through the log. A caller that *can* be told — a test, and
237/// one day an admin route — passes a channel and learns the outcome instead of
238/// polling for a side effect.
239pub struct ReloadRequest {
240    pub respond: Option<oneshot::Sender<Result<ReloadReport, ReloadError>>>,
241}
242
243/// The handle a signal handler (or a future admin route) triggers reloads with.
244#[derive(Clone)]
245pub struct ReloadHandle(mpsc::Sender<ReloadRequest>);
246
247impl ReloadHandle {
248    /// Asks for a reload, without waiting for the outcome.
249    ///
250    /// `try_send` on a capacity-one channel, so a signal storm coalesces into
251    /// one reload rather than queueing a run of identical ones. A refusal here
252    /// means a reload is already pending, which is the same answer.
253    pub fn trigger(&self) -> bool {
254        self.0.try_send(ReloadRequest { respond: None }).is_ok()
255    }
256
257    /// Asks for a reload and waits for what happened.
258    ///
259    /// # Errors
260    ///
261    /// Returns the [`ReloadError`] the reload failed with. A dropped supervisor
262    /// — the server is shutting down — surfaces as [`ReloadError::Load`].
263    pub async fn reload(&self) -> Result<ReloadReport, ReloadError> {
264        let (respond, answer) = oneshot::channel();
265        self.0
266            .send(ReloadRequest {
267                respond: Some(respond),
268            })
269            .await
270            .map_err(|_| ReloadError::Load("the server is not accepting reloads".to_string()))?;
271        answer
272            .await
273            .map_err(|_| ReloadError::Load("the reload was abandoned".to_string()))?
274    }
275}
276
277/// The receiving half, held by the serving path.
278pub struct Reloads(mpsc::Receiver<ReloadRequest>);
279
280impl Reloads {
281    /// A source that never fires, for every caller that serves no reloads.
282    ///
283    /// The sender is dropped on the spot, so the supervisor's first `recv`
284    /// yields `None` and it exits — which is what keeps the no-reload path free
285    /// rather than a second branch through the serving code.
286    #[must_use]
287    pub fn none() -> Self {
288        Self(mpsc::channel(1).1)
289    }
290
291    pub(crate) async fn recv(&mut self) -> Option<ReloadRequest> {
292        self.0.recv().await
293    }
294}
295
296/// Opens a reload channel.
297///
298/// Capacity one: see [`ReloadHandle::trigger`] for why coalescing is the wanted
299/// behaviour rather than a limitation.
300#[must_use]
301pub fn channel() -> (ReloadHandle, Reloads) {
302    let (sender, receiver) = mpsc::channel(1);
303    (ReloadHandle(sender), Reloads(receiver))
304}
305
306/// The service half of one router cell.
307///
308/// Delegates every request to whichever router is current. Two things about the
309/// shape are deliberate:
310///
311/// - It is a **fallback service, not a make-service**. A make-service is
312///   consulted once per *connection*, so an HTTP/1.1 keep-alive client would
313///   hold the old router for the life of its connection — which for an ACME
314///   client polling an order is exactly the window a reload is meant to affect.
315///   Per request is the only granularity that means anything here.
316/// - It is wrapped by an outer [`Router`] rather than handed to `axum::serve`
317///   directly, so `into_make_service_with_connect_info` still applies. That
318///   inserts `ConnectInfo` into the request *before* the outer router runs, so
319///   the inner router — and every IP filter under it — sees it exactly as it
320///   does today.
321#[derive(Clone)]
322struct SwapService(watch::Receiver<RouterIntoService<Body>>);
323
324impl Service<Request> for SwapService {
325    type Response = Response;
326    type Error = Infallible;
327    type Future = <RouterIntoService<Body> as Service<Request>>::Future;
328
329    fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
330        // The inner router is always ready, and asking the *current* one here
331        // would be asking a different service than the one `call` ends up using.
332        Poll::Ready(Ok(()))
333    }
334
335    fn call(&mut self, request: Request) -> Self::Future {
336        // Cloned out of the cell so the borrow guard is gone before the call:
337        // a `Router` clone is one `Arc` bump, which is why this can happen per
338        // request rather than per connection.
339        let mut current = self.0.borrow().clone();
340        current.call(request)
341    }
342}
343
344/// Wraps a router cell as one servable [`Router`].
345///
346/// Hand the result to `axum::serve` exactly as an ordinary router: it routes
347/// nothing itself, so every path falls through to the current inner router.
348///
349/// One consequence worth knowing rather than rediscovering: nesting means two
350/// `top_level` route futures instead of one, so axum's response fixups run
351/// twice. Every one of them is idempotent — `set_content_length` returns early
352/// when the header is already present, `set_allow_header` does nothing when the
353/// outer router has no `allow_header` of its own, and stripping an
354/// already-empty `HEAD` body is a no-op. `HEAD /newNonce` and `Content-Length`
355/// therefore behave exactly as they do without the wrapper.
356pub fn swappable(current: watch::Receiver<RouterIntoService<Body>>) -> Router {
357    Router::new().fallback_service(SwapService(current))
358}
359
360/// Opens a router cell, with `initial` as its first generation.
361#[must_use]
362pub fn router_channel(
363    initial: Router,
364) -> (
365    watch::Sender<RouterIntoService<Body>>,
366    watch::Receiver<RouterIntoService<Body>>,
367) {
368    watch::channel(initial.into_service::<Body>())
369}
370
371#[cfg(test)]
372mod channel_tests {
373    use super::*;
374
375    /// A signal storm coalesces into one reload rather than queueing a run of
376    /// identical ones. `trigger` answering `false` is not a lost request — the
377    /// reload it would have asked for is already pending.
378    #[tokio::test]
379    async fn a_second_trigger_while_one_is_pending_coalesces() {
380        let (handle, mut reloads) = channel();
381
382        assert!(handle.trigger(), "the first request is accepted");
383        assert!(!handle.trigger(), "the second finds one already queued");
384
385        let request = reloads.recv().await.expect("the queued request arrives");
386        assert!(
387            request.respond.is_none(),
388            "a signal has nobody to answer, so it asks for no channel"
389        );
390
391        // With the queue drained, the next signal is accepted again.
392        assert!(handle.trigger());
393    }
394
395    /// A handle whose supervisor is gone answers rather than hanging. That is
396    /// the shutdown case: the serving task returned and took the receiver with
397    /// it, and a caller waiting on `reload()` must not wait for ever.
398    #[tokio::test]
399    async fn a_reload_with_no_supervisor_left_fails_rather_than_hanging() {
400        let (handle, reloads) = channel();
401        drop(reloads);
402
403        let error = handle
404            .reload()
405            .await
406            .expect_err("there is nobody to serve the request");
407        assert_eq!(error.kind(), "load_failed");
408        assert!(
409            error.to_string().contains("not accepting reloads"),
410            "{error}"
411        );
412    }
413
414    /// The no-reload source ends the supervisor immediately, which is what
415    /// keeps every caller that serves no reloads free of a parked task.
416    #[tokio::test]
417    async fn a_source_that_never_fires_ends_at_once() {
418        assert!(Reloads::none().recv().await.is_none());
419    }
420}
421
422#[cfg(test)]
423mod frozen_tests {
424    use super::*;
425    use acme_proxy_core::config::ProfileSections;
426
427    fn profile(name: &str) -> ProfileConfig {
428        ProfileConfig {
429            name: name.to_string(),
430            sections: ProfileSections::default(),
431        }
432    }
433
434    /// The shape every case below uses: a configuration and its resolved
435    /// profiles, mutated by the case and compared against an untouched pair.
436    fn refuse(
437        mutate: impl FnOnce(&mut Config, &mut Vec<ProfileConfig>),
438    ) -> Result<(), ReloadError> {
439        let applied = Config::default();
440        let applied_profiles = vec![profile("le")];
441
442        let mut proposed = Config::default();
443        let mut proposed_profiles = vec![profile("le")];
444        mutate(&mut proposed, &mut proposed_profiles);
445
446        check_frozen(
447            &Applied {
448                config: &applied,
449                profiles: &applied_profiles,
450            },
451            &Applied {
452                config: &proposed,
453                profiles: &proposed_profiles,
454            },
455        )
456    }
457
458    fn refused_key(result: Result<(), ReloadError>) -> String {
459        match result {
460            Err(ReloadError::Frozen { key, .. }) => key,
461            Err(other) => panic!("expected a frozen-key refusal, got {other}"),
462            Ok(()) => panic!("expected a refusal, the change was allowed"),
463        }
464    }
465
466    /// Every frozen key, changed one at a time, refused **by its own name**.
467    ///
468    /// Table-driven for the usual reason, and doubly load-bearing here: a
469    /// projection that silently reads the wrong field would make its key's case
470    /// come back `Ok` (nothing changed as far as the table can see) or name some
471    /// *other* key. Both are failures, so this is the completeness check as well
472    /// as the naming one — a key added to `FROZEN` with no row here is caught by
473    /// the comparison of the two key sets at the end.
474    #[test]
475    fn every_frozen_key_is_refused_by_its_own_name() {
476        #[allow(clippy::type_complexity)]
477        let cases: Vec<(&str, Box<dyn Fn(&mut Config, &mut Vec<ProfileConfig>)>)> = vec![(
478            "database.url",
479            Box::new(|c: &mut Config, _: &mut Vec<ProfileConfig>| {
480                c.database.url = "sqlite://other.db".to_string();
481            }),
482        )];
483
484        for (key, mutate) in &cases {
485            let refused = refused_key(refuse(|config, profiles| mutate(config, profiles)));
486            assert_eq!(
487                &refused.as_str(),
488                key,
489                "changing `{key}` must be refused naming `{key}`, not `{refused}`",
490            );
491        }
492
493        // The guard on the guard: a case list that drifted from the table would
494        // make every assertion above pass while leaving a key untested.
495        let covered: std::collections::BTreeSet<&str> = cases.iter().map(|(key, _)| *key).collect();
496        let table: std::collections::BTreeSet<&str> = FROZEN.iter().map(|(key, _)| *key).collect();
497        assert_eq!(
498            covered, table,
499            "every FROZEN entry needs a case here, and every case needs an entry",
500        );
501    }
502
503    /// The baseline: an unchanged configuration is not refused. Without this,
504    /// a projection that returned a fresh value each call would pass every
505    /// assertion above and refuse every reload in production.
506    #[test]
507    fn an_unchanged_configuration_is_allowed() {
508        assert!(refuse(|_, _| {}).is_ok());
509    }
510
511    /// The four keys this table used to hold, every one of them now reloadable.
512    ///
513    /// Beside the freeze rather than only in `tests/reload.rs`, for
514    /// `every_listener_key_is_reloadable`'s reason and more sharply: the table is
515    /// consulted **first**, so any of these left in it would make the whole
516    /// rebuild path unreachable — the refusal lands before a single backend is
517    /// built.
518    ///
519    /// What makes each safe is the reuse pass in `signer::build_backends` and
520    /// the backends' state living in the database; what proves it is that
521    /// module's own suite, which drives a real revocation across a rebuild.
522    /// This one only proves the refusal is gone.
523    #[test]
524    fn the_profile_set_its_signers_and_the_egress_all_reload() {
525        // A profile mounted, and a profile renamed at the same count.
526        assert!(refuse(|_, profiles| profiles.push(profile("staging"))).is_ok());
527        assert!(refuse(|_, profiles| profiles[0].name = "staging".to_string()).is_ok());
528        assert!(refuse(|_, profiles| profiles.clear()).is_ok());
529
530        // A resolved `[signer]` moved — the change a naive implementation
531        // confused with the global one, and the one the seam exists for.
532        assert!(
533            refuse(|_, profiles| profiles[0].sections.signer.backend = "custom".to_string())
534                .is_ok()
535        );
536        assert!(
537            refuse(|_, profiles| profiles[0].sections.signer.local_ca.leaf_validity_days = 30)
538                .is_ok()
539        );
540
541        // And the two that were frozen only because the signers cached them.
542        assert!(refuse(|c, _| c.dns.resolver = Some("192.0.2.1:53".to_string())).is_ok());
543        assert!(refuse(|c, _| c.proxy.https_url = "http://proxy.example:3128".to_string()).is_ok());
544    }
545
546    /// The global `[signer]` section was never frozen and still is not — but the
547    /// reason has inverted, and the inversion is worth pinning.
548    ///
549    /// It used to be allowed because `merged_sections` overlays it per key, so a
550    /// change every profile overrides is a genuine no-op that must not be
551    /// refused. It is now allowed because **nothing about `[signer]` is refused
552    /// at all**: a change that does reach a profile's resolved section rebuilds
553    /// that profile's backend instead of stopping the reload.
554    #[test]
555    fn nothing_about_the_signer_sections_is_refused_any_more() {
556        assert!(refuse(|config, _| config.signer.backend = "custom".to_string()).is_ok());
557        assert!(
558            refuse(|config, profiles| {
559                config.signer.backend = "custom".to_string();
560                profiles[0].sections.signer.backend = "custom".to_string();
561            })
562            .is_ok()
563        );
564    }
565
566    /// Every `[jobs]` key reloads, where six of the seven used to be refused.
567    ///
568    /// They reach the runner by three different routes and the test drives all
569    /// three deliberately: `retention_days` through a rebuilt `SweepJob` in the
570    /// new registry, `max_attempts` through the atomic on `JobQueue`, and the
571    /// other five through the config cell the loop re-reads each pass.
572    ///
573    /// Beside the freeze rather than only in the suite that drives a real
574    /// runner, for `every_listener_key_is_reloadable`'s reason: this table is
575    /// consulted *first*, so a key left in it would make the whole swap path
576    /// below unreachable — the refusal lands before anything is built.
577    #[test]
578    fn every_jobs_key_is_reloadable() {
579        for mutate in [
580            |c: &mut Config| c.jobs.poll_interval_ms += 1,
581            |c: &mut Config| c.jobs.max_concurrent += 1,
582            |c: &mut Config| c.jobs.max_attempts += 1,
583            |c: &mut Config| c.jobs.retry_base_seconds += 1,
584            |c: &mut Config| c.jobs.retry_max_seconds += 1,
585            |c: &mut Config| c.jobs.lease_seconds += 1,
586            |c: &mut Config| c.jobs.retention_days += 1,
587        ] {
588            assert!(
589                refuse(|config, _| mutate(config)).is_ok(),
590                "nothing in `[jobs]` is snapshotted at spawn any more, so no key \
591                 in it is frozen",
592            );
593        }
594    }
595
596    /// Every `[logging]` key reloads, where the whole section used to be
597    /// refused: `server::logging` installs the stack behind a `reload::Layer`
598    /// handle, so a swap replaces all six at once. Driven through the two that
599    /// change the stack's *shape* as well as the filter, since the filter alone
600    /// reloading was the cheap half of the problem.
601    #[test]
602    fn every_logging_key_is_reloadable() {
603        for mutate in [
604            |c: &mut Config| c.logging.filter = "acme_proxy=debug".to_string(),
605            |c: &mut Config| c.logging.json_format = true,
606            |c: &mut Config| c.logging.flatten_event = true,
607            |c: &mut Config| c.logging.target = "stderr".to_string(),
608            |c: &mut Config| c.logging.ansi = false,
609            |c: &mut Config| c.logging.span_events = "close".to_string(),
610        ] {
611            assert!(
612                refuse(|config, _| mutate(config)).is_ok(),
613                "the layer stack is swapped whole, so no `[logging]` key is frozen",
614            );
615        }
616    }
617
618    /// Every key that decides where a socket is, or whether there is one,
619    /// reloads — the seven this table used to hold.
620    ///
621    /// The table is what a reload consults *first*, so a key left in here would
622    /// make `server::sockets::plan_sockets` unreachable code and the whole
623    /// rebinding path dead: the refusal happens before anything is built, let
624    /// alone bound. That is why this sits beside the freeze rather than only in
625    /// the suite that drives a real socket.
626    #[test]
627    fn every_listener_key_is_reloadable() {
628        for mutate in [
629            |c: &mut Config| c.server.bind_address = "127.0.0.1:9999".to_string(),
630            |c: &mut Config| c.server.tls.enabled = !c.server.tls.enabled,
631            |c: &mut Config| c.admin.enabled = !c.admin.enabled,
632            |c: &mut Config| c.admin.bind_address = "127.0.0.1:9998".to_string(),
633            |c: &mut Config| c.admin.tls.enabled = !c.admin.tls.enabled,
634            |c: &mut Config| c.metrics.enabled = !c.metrics.enabled,
635            |c: &mut Config| c.metrics.bind_address = "127.0.0.1:9997".to_string(),
636        ] {
637            assert!(
638                refuse(|config, _| mutate(config)).is_ok(),
639                "a socket is replaceable now, so no bind address or listener \
640                 switch is frozen",
641            );
642        }
643    }
644
645    /// A refusal an operator can act on names the key *and* both values — "it
646    /// says `x` but is running `y`" is the whole diagnosis.
647    #[test]
648    fn a_refusal_names_the_key_and_both_values() {
649        let error = refuse(|config, _| {
650            config.database.url = "sqlite://elsewhere.db".to_string();
651        })
652        .expect_err("a changed database URL is refused");
653
654        let rendered = error.to_string();
655        assert!(rendered.contains("database.url"), "{rendered}");
656        assert!(rendered.contains("sqlite://sqlite.db"), "{rendered}");
657        assert!(rendered.contains("sqlite://elsewhere.db"), "{rendered}");
658        assert_eq!(error.kind(), "frozen_key");
659    }
660
661    /// And names them with the password taken out.
662    ///
663    /// This message is logged, so a PostgreSQL DSN reaching it verbatim would
664    /// put the database password in the operator's log the first time somebody
665    /// sent `SIGHUP` after editing the URL. The host still has to survive it —
666    /// a refusal that hid *which* database it meant would not be actionable.
667    #[test]
668    fn a_refusal_over_a_dsn_keeps_the_host_and_drops_the_password() {
669        let error = refuse(|config, _| {
670            config.database.url = "postgres://acme:hunter2@db.internal/acme".to_string();
671        })
672        .expect_err("a changed database URL is refused");
673
674        let rendered = error.to_string();
675        assert!(
676            !rendered.contains("hunter2"),
677            "the password must not reach a log line: {rendered}"
678        );
679        assert!(rendered.contains("db.internal"), "{rendered}");
680        assert!(rendered.contains("postgres://acme:***@"), "{rendered}");
681    }
682
683    /// The other two variants read as what they are: a file that would not load
684    /// and a configuration that would not build are an operator's problem and
685    /// the server's respectively, and the `kind` tag is what a log filter uses.
686    #[test]
687    fn the_other_failures_describe_themselves() {
688        let load = ReloadError::Load("no such file".to_string());
689        assert_eq!(load.kind(), "load_failed");
690        assert!(load.to_string().contains("did not load"), "{load}");
691
692        let build = ReloadError::Build("bad filter rule".to_string());
693        assert_eq!(build.kind(), "build_failed");
694        assert!(build.to_string().contains("did not build"), "{build}");
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use axum::body::to_bytes;
702    use axum::routing::get;
703    use http_body_util::BodyExt;
704    use tower::ServiceExt;
705
706    fn answering(body: &'static str) -> Router {
707        Router::new().route("/", get(move || async move { body }))
708    }
709
710    async fn body_of(response: Response) -> String {
711        String::from_utf8(
712            to_bytes(response.into_body(), usize::MAX)
713                .await
714                .unwrap()
715                .to_vec(),
716        )
717        .unwrap()
718    }
719
720    /// The property the reload path rests on: a request served *after* the swap
721    /// reaches the new router, through a service value that never moved.
722    #[tokio::test]
723    async fn a_request_after_a_swap_reaches_the_new_router() {
724        let (sender, receiver) = router_channel(answering("first"));
725        let app = swappable(receiver);
726
727        let response = app
728            .clone()
729            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
730            .await
731            .unwrap();
732        assert_eq!(body_of(response).await, "first");
733
734        sender.send_replace(answering("second").into_service::<Body>());
735
736        let response = app
737            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
738            .await
739            .unwrap();
740        assert_eq!(body_of(response).await, "second");
741    }
742
743    /// A path the inner router does not serve still gets the inner router's own
744    /// answer, not the wrapper's. The wrapper routes nothing: if it did, it
745    /// would be shadowing whatever fallback each generation installs — and both
746    /// of this crate's routers install one deliberately (an ACME problem
747    /// document, and the admin panel's HTML page).
748    #[tokio::test]
749    async fn the_wrapper_never_answers_in_place_of_the_router_it_holds() {
750        let inner = answering("routed").fallback(|| async { "inner fallback" });
751        let (_sender, receiver) = router_channel(inner);
752
753        let response = swappable(receiver)
754            .oneshot(
755                Request::builder()
756                    .uri("/nothing-here")
757                    .body(Body::empty())
758                    .unwrap(),
759            )
760            .await
761            .unwrap();
762        assert_eq!(body_of(response).await, "inner fallback");
763    }
764
765    /// `HEAD` is the case the double-`top_level` nesting could plausibly break:
766    /// axum strips the body and sets `Content-Length` at the top of a route
767    /// future, and there are now two of those. Both fixups are idempotent, and
768    /// this is the regression test saying so — §7.2 has `newNonce` answer `HEAD`.
769    #[tokio::test]
770    async fn a_head_request_keeps_its_content_length_and_loses_its_body() {
771        let (_sender, receiver) = router_channel(answering("first"));
772
773        let response = swappable(receiver)
774            .oneshot(
775                Request::builder()
776                    .method("HEAD")
777                    .uri("/")
778                    .body(Body::empty())
779                    .unwrap(),
780            )
781            .await
782            .unwrap();
783
784        assert_eq!(
785            response.headers().get("content-length").unwrap(),
786            "5",
787            "the length of the body a GET would have returned"
788        );
789        assert!(
790            response
791                .into_body()
792                .collect()
793                .await
794                .unwrap()
795                .to_bytes()
796                .is_empty(),
797            "a HEAD response carries no body"
798        );
799    }
800}