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}