Skip to main content

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