1use std::any::{Any, TypeId};
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::Arc;
32
33use futures::FutureExt as _;
34use tracing::Instrument as _;
35
36use crate::config::{AutumnConfig, ConfigLoader};
37#[cfg(feature = "maud")]
38use crate::error_pages::{ErrorPageRenderer, SharedRenderer};
39use crate::middleware::exception_filter::ExceptionFilter;
40#[cfg(feature = "db")]
41use crate::migrate;
42use crate::route::Route;
43use crate::state::AppState;
44
45#[must_use]
68pub fn app() -> AppBuilder {
69 AppBuilder {
70 routes: Vec::new(),
71 api_versions: Vec::new(),
72 route_sources: Vec::new(),
73 current_plugin: None,
74 tasks: Vec::new(),
75 one_off_tasks: Vec::new(),
76 jobs: Vec::new(),
77 listeners: Vec::new(),
78 static_metas: Vec::new(),
79 exception_filters: Vec::new(),
80 scoped_groups: Vec::new(),
81 merge_routers: Vec::new(),
82 nest_routers: Vec::new(),
83 custom_layers: Vec::new(),
84 static_gate_layers: Vec::new(),
85 startup_hooks: Vec::new(),
86 state_initializers: Vec::new(),
87 shutdown_hooks: Vec::new(),
88 extensions: HashMap::new(),
89 registered_plugins: HashSet::new(),
90 plugin_config_roots: BTreeSet::new(),
91 #[cfg(feature = "maud")]
92 error_page_renderer: None,
93 #[cfg(feature = "db")]
94 migrations: Vec::new(),
95 config_loader_factory: None,
96 #[cfg(feature = "db")]
97 pool_provider_factory: None,
98 #[cfg(feature = "db")]
99 shard_provider_factory: None,
100 #[cfg(feature = "db")]
101 shard_router: None,
102 #[cfg(feature = "db")]
103 directory_shard_router: false,
104 telemetry_provider: None,
105 session_store: None,
106 #[cfg(feature = "ws")]
107 channels_backend: None,
108 #[cfg(feature = "storage")]
109 blob_store: None,
110 cache_backend: None,
111 #[cfg(feature = "reporting")]
112 error_reporters: Vec::new(),
113 alert_channels: Vec::new(),
114 #[cfg(feature = "openapi")]
115 openapi: None,
116 #[cfg(feature = "mcp")]
117 mcp: None,
118 audit_logger: None,
119 #[cfg(feature = "i18n")]
120 i18n_bundle: None,
121 #[cfg(feature = "i18n")]
122 i18n_auto_load: false,
123 #[cfg(feature = "embed-assets")]
124 embedded_static: None,
125 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
126 embedded_locales: None,
127 policy_registrations: Vec::new(),
128 #[cfg(feature = "mail")]
129 mail_delivery_queue_factory: None,
130 #[cfg(feature = "mail")]
131 suppression_store: None,
132 #[cfg(feature = "mail")]
133 mail_suppression_store: None,
134 #[cfg(feature = "mail")]
135 mount_unsubscribe_endpoint: false,
136 #[cfg(feature = "mail")]
137 mail_previews: Vec::new(),
138 #[cfg(feature = "maud")]
139 story_gallery: None,
140 declared_routes: Vec::new(),
141 idempotency_enabled: false,
142 #[cfg(feature = "mail")]
143 mail_interceptor: None,
144 job_interceptor: None,
145 #[cfg(feature = "db")]
146 db_interceptor: None,
147 #[cfg(feature = "ws")]
148 channels_interceptor: None,
149 #[cfg(feature = "oauth2")]
150 http_interceptor: None,
151 seo_sources: Vec::new(),
152 metrics_sources: Vec::new(),
153 health_indicators: Vec::new(),
154 #[cfg(feature = "inbound-mail")]
155 inbound_mail_router: None,
156 }
157}
158
159fn omitted_router_count<'a>(
176 merge_routers: usize,
177 nest_prefixes: impl IntoIterator<Item = &'a str>,
178 declared_routes: &[crate::route_listing::RouteInfo],
179) -> usize {
180 let uncovered_nests = nest_prefixes
181 .into_iter()
182 .filter(|prefix| !nest_prefix_is_covered(prefix, declared_routes))
183 .count();
184 merge_routers + uncovered_nests
185}
186
187fn nest_prefix_is_covered(
191 prefix: &str,
192 declared_routes: &[crate::route_listing::RouteInfo],
193) -> bool {
194 declared_routes
195 .iter()
196 .any(|route| path_is_under_prefix(&route.path, prefix))
197}
198
199fn path_is_under_prefix(path: &str, prefix: &str) -> bool {
204 let prefix = prefix.trim_end_matches('/');
205 if prefix.is_empty() {
206 return true;
207 }
208 path == prefix
209 || path
210 .strip_prefix(prefix)
211 .is_some_and(|rest| rest.starts_with('/'))
212}
213
214type StartupHookFuture = Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send>>;
215type StartupHook = Box<dyn Fn(AppState) -> StartupHookFuture + Send + Sync>;
216type StateInitializer = Box<dyn FnOnce(&AppState) + Send>;
217type ShutdownHookFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
218type ShutdownHook = Box<dyn Fn() -> ShutdownHookFuture + Send + Sync>;
219
220type ConfigLoaderFactory = Box<
228 dyn FnOnce() -> Pin<
229 Box<dyn Future<Output = Result<AutumnConfig, crate::config::ConfigError>> + Send>,
230 > + Send,
231>;
232#[cfg(feature = "db")]
233type PoolProviderFactory = Box<
234 dyn FnOnce(
235 crate::config::DatabaseConfig,
236 ) -> Pin<
237 Box<
238 dyn Future<
239 Output = Result<Option<crate::db::DatabaseTopology>, crate::db::PoolError>,
240 > + Send,
241 >,
242 > + Send,
243>;
244#[cfg(feature = "db")]
247type ShardProviderFactory = Box<
248 dyn FnOnce(
249 crate::config::DatabaseConfig,
250 ) -> Pin<
251 Box<
252 dyn Future<Output = Result<Vec<crate::db::DatabaseTopology>, crate::db::PoolError>>
253 + Send,
254 >,
255 > + Send,
256>;
257
258type PolicyRegistration = Box<dyn FnOnce(&crate::authorization::PolicyRegistry) + Send>;
261
262#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
264pub struct ApiVersion {
265 pub version: String,
267 pub deprecated_at: Option<chrono::DateTime<chrono::Utc>>,
269 pub sunset_at: Option<chrono::DateTime<chrono::Utc>>,
271}
272
273#[derive(Clone, Debug)]
275pub struct RegisteredApiVersions(pub Vec<ApiVersion>);
276
277#[allow(clippy::struct_excessive_bools)]
306pub struct AppBuilder {
307 pub(crate) routes: Vec<Route>,
308 pub api_versions: Vec<ApiVersion>,
310 route_sources: Vec<crate::route_listing::RouteSource>,
312 current_plugin: Option<String>,
315 tasks: Vec<crate::task::TaskInfo>,
316 one_off_tasks: Vec<crate::task::OneOffTaskInfo>,
317 pub(crate) jobs: Vec<crate::job::JobInfo>,
318 pub(crate) listeners: Vec<crate::events::ListenerInfo>,
321 pub(crate) static_metas: Vec<crate::static_gen::StaticRouteMeta>,
322 pub(crate) exception_filters: Vec<Arc<dyn ExceptionFilter>>,
323 pub(crate) scoped_groups: Vec<ScopedGroup>,
324 pub(crate) merge_routers: Vec<axum::Router<AppState>>,
325 pub(crate) nest_routers: Vec<(String, axum::Router<AppState>)>,
326 pub(crate) custom_layers: Vec<CustomLayerRegistration>,
329 pub(crate) static_gate_layers: Vec<CustomLayerRegistration>,
334 pub(crate) startup_hooks: Vec<StartupHook>,
335 pub(crate) state_initializers: Vec<StateInitializer>,
336 pub(crate) shutdown_hooks: Vec<ShutdownHook>,
337 pub(crate) extensions: HashMap<TypeId, Box<dyn Any + Send>>,
338 pub(crate) registered_plugins: HashSet<String>,
340 pub(crate) plugin_config_roots: BTreeSet<String>,
345 #[cfg(feature = "maud")]
347 error_page_renderer: Option<SharedRenderer>,
348 #[cfg(feature = "db")]
350 migrations: Vec<migrate::EmbeddedMigrations>,
351 config_loader_factory: Option<ConfigLoaderFactory>,
354 #[cfg(feature = "db")]
357 pool_provider_factory: Option<PoolProviderFactory>,
358 #[cfg(feature = "db")]
361 shard_provider_factory: Option<ShardProviderFactory>,
362 #[cfg(feature = "db")]
366 shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
367 #[cfg(feature = "db")]
370 directory_shard_router: bool,
371 telemetry_provider: Option<Box<dyn crate::telemetry::TelemetryProvider>>,
374 session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
378 #[cfg(feature = "ws")]
381 channels_backend: Option<Arc<dyn crate::channels::ChannelsBackend>>,
382 #[cfg(feature = "storage")]
386 blob_store: Option<crate::storage::SharedBlobStore>,
387 cache_backend: Option<Arc<dyn crate::cache::Cache>>,
391 #[cfg(feature = "reporting")]
397 pub(crate) error_reporters: Vec<Arc<dyn crate::reporting::ErrorReporter>>,
398 pub(crate) alert_channels: Vec<Arc<dyn crate::alerts::AlertChannel>>,
404 #[cfg(feature = "openapi")]
412 openapi: Option<crate::openapi::OpenApiConfig>,
413 #[cfg(feature = "mcp")]
418 mcp: Option<crate::mcp::McpRuntime>,
419 audit_logger: Option<Arc<crate::audit::AuditLogger>>,
421 #[cfg(feature = "i18n")]
425 i18n_bundle: Option<Arc<crate::i18n::Bundle>>,
426 #[cfg(feature = "i18n")]
430 i18n_auto_load: bool,
431 #[cfg(feature = "embed-assets")]
436 embedded_static: Option<crate::assets::EmbeddedStaticDir>,
437 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
442 embedded_locales: Option<&'static include_dir::Dir<'static>>,
443 policy_registrations: Vec<PolicyRegistration>,
449 #[cfg(feature = "mail")]
453 mail_delivery_queue_factory: Option<MailDeliveryQueueFactory>,
454 #[cfg(feature = "mail")]
455 pub(crate) suppression_store: Option<crate::mail::SuppressionStoreHandle>,
456 #[cfg(feature = "mail")]
457 pub(crate) mail_suppression_store: Option<crate::mail::suppression::SuppressionStoreHandle>,
458 #[cfg(feature = "mail")]
459 pub(crate) mount_unsubscribe_endpoint: bool,
460 #[cfg(feature = "mail")]
462 mail_previews: Vec<crate::mail::MailPreview>,
463 #[cfg(feature = "maud")]
465 story_gallery: Option<crate::stories::StoryGallery>,
466 declared_routes: Vec<crate::route_listing::RouteInfo>,
470 idempotency_enabled: bool,
474 #[cfg(feature = "mail")]
475 mail_interceptor: Option<Arc<dyn crate::interceptor::MailInterceptor>>,
476 job_interceptor: Option<Arc<dyn crate::interceptor::JobInterceptor>>,
477 #[cfg(feature = "db")]
478 db_interceptor: Option<Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
479 #[cfg(feature = "ws")]
480 channels_interceptor: Option<Arc<dyn crate::interceptor::ChannelsInterceptor>>,
481 #[cfg(feature = "oauth2")]
482 http_interceptor: Option<Arc<dyn crate::interceptor::HttpInterceptor>>,
483 seo_sources: Vec<Arc<dyn crate::seo::SitemapSource>>,
486
487 pub(crate) metrics_sources: Vec<(String, Arc<dyn crate::actuator::MetricsSource>)>,
489 pub(crate) health_indicators: Vec<(
491 String,
492 crate::actuator::IndicatorGroup,
493 Arc<dyn crate::actuator::HealthIndicator>,
494 )>,
495 #[cfg(feature = "inbound-mail")]
499 pub(crate) inbound_mail_router: Option<Arc<crate::inbound_mail::InboundMailRouter>>,
500}
501
502#[cfg(feature = "mail")]
506pub(crate) type MailDeliveryQueueFactory = Box<
507 dyn FnOnce(&AppState) -> crate::AutumnResult<Arc<dyn crate::mail::MailDeliveryQueue>> + Send,
508>;
509
510pub struct ScopedGroup {
515 pub prefix: String,
516 pub routes: Vec<Route>,
517 pub source: crate::route_listing::RouteSource,
519 pub apply_layer: Box<dyn FnOnce(axum::Router<AppState>) -> axum::Router<AppState> + Send>,
521}
522
523pub(crate) type CustomLayerApplier =
529 Box<dyn FnOnce(axum::Router<AppState>) -> axum::Router<AppState> + Send>;
530
531pub(crate) struct CustomLayerRegistration {
533 pub(crate) type_id: TypeId,
535 pub(crate) type_name: &'static str,
538 pub(crate) apply: CustomLayerApplier,
540}
541
542mod sealed {
543 pub trait Sealed {}
544}
545
546#[diagnostic::on_unimplemented(
560 message = "`{Self}` is not a usable Autumn app-wide Tower layer",
561 label = "this type does not implement `tower::Layer<axum::routing::Route>` with the required service bounds",
562 note = "`AppBuilder::layer(..)` requires:\n L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,\n L::Service: Service<axum::extract::Request, Response = axum::response::Response, Error = Infallible> + Clone + Send + Sync + 'static,\n <L::Service as Service<axum::extract::Request>>::Future: Send + 'static\nSee docs/guide/middleware.md for common patterns and how to wrap raw-error layers (e.g. TimeoutLayer) with HandleErrorLayer."
563)]
564pub trait IntoAppLayer: sealed::Sealed + Send + Sync + 'static {
565 #[doc(hidden)]
567 fn apply_to(self, router: axum::Router<AppState>) -> axum::Router<AppState>;
568}
569
570impl<L> sealed::Sealed for L
571where
572 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
573 L::Service: tower::Service<
574 axum::extract::Request,
575 Response = axum::response::Response,
576 Error = std::convert::Infallible,
577 > + Clone
578 + Send
579 + Sync
580 + 'static,
581 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
582{
583}
584
585impl<L> IntoAppLayer for L
586where
587 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
588 L::Service: tower::Service<
589 axum::extract::Request,
590 Response = axum::response::Response,
591 Error = std::convert::Infallible,
592 > + Clone
593 + Send
594 + Sync
595 + 'static,
596 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
597{
598 fn apply_to(self, router: axum::Router<AppState>) -> axum::Router<AppState> {
599 router.layer(self)
600 }
601}
602
603impl AppBuilder {
604 #[must_use]
626 pub fn routes(mut self, routes: Vec<Route>) -> Self {
627 let source = self
628 .current_plugin
629 .as_ref()
630 .map_or(crate::route_listing::RouteSource::User, |name| {
631 crate::route_listing::RouteSource::Plugin(name.clone())
632 });
633 for _ in &routes {
634 self.route_sources.push(source.clone());
635 }
636 self.routes.extend(routes);
637 self
638 }
639
640 #[must_use]
646 pub fn tasks(mut self, tasks: Vec<crate::task::TaskInfo>) -> Self {
647 self.tasks.extend(tasks);
648 self
649 }
650
651 #[must_use]
656 pub fn one_off_tasks(mut self, tasks: Vec<crate::task::OneOffTaskInfo>) -> Self {
657 self.one_off_tasks.extend(tasks);
658 self
659 }
660
661 #[must_use]
663 pub fn jobs(mut self, jobs: Vec<crate::job::JobInfo>) -> Self {
664 self.jobs.extend(jobs);
665 self
666 }
667
668 #[must_use]
675 pub fn listeners(mut self, listeners: Vec<crate::events::ListenerInfo>) -> Self {
676 self.listeners.extend(listeners);
677 self
678 }
679
680 #[must_use]
685 pub fn static_routes(mut self, metas: Vec<crate::static_gen::StaticRouteMeta>) -> Self {
686 self.static_metas.extend(metas);
687 self
688 }
689
690 #[must_use]
728 pub fn seo_source<S: crate::seo::SitemapSource + 'static>(mut self, source: S) -> Self {
729 self.seo_sources.push(Arc::new(source));
730 self
731 }
732
733 #[cfg(feature = "openapi")]
781 #[must_use]
782 pub fn openapi(mut self, config: crate::openapi::OpenApiConfig) -> Self {
783 self.openapi = Some(config);
784 self
785 }
786
787 #[cfg(feature = "mcp")]
827 #[must_use]
828 pub fn mount_mcp(mut self, path: impl Into<String>) -> Self {
829 let path = path.into();
830 if let Some(rt) = self.mcp.as_mut() {
831 rt.mount_path = path;
832 } else {
833 self.mcp = Some(crate::mcp::McpRuntime::new(path));
834 }
835 self
836 }
837
838 #[cfg(feature = "mcp")]
852 #[must_use]
853 pub fn expose_all_as_mcp(mut self) -> Self {
854 if let Some(rt) = self.mcp.as_mut() {
855 rt.expose_all = true;
856 } else {
857 let mut rt = crate::mcp::McpRuntime::new("/mcp");
858 rt.expose_all = true;
859 self.mcp = Some(rt);
860 }
861 self
862 }
863
864 #[cfg(feature = "mcp")]
877 #[must_use]
878 pub fn secure_mcp<L>(mut self, layer: L) -> Self
879 where
880 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
881 L::Service: tower::Service<
882 axum::http::Request<axum::body::Body>,
883 Response = axum::http::Response<axum::body::Body>,
884 Error = std::convert::Infallible,
885 > + Clone
886 + Send
887 + Sync
888 + 'static,
889 <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
890 Send + 'static,
891 {
892 let applier: crate::mcp::McpEndpointLayer = Box::new(move |router| router.layer(layer));
893 if let Some(rt) = self.mcp.as_mut() {
894 rt.endpoint_layer = Some(applier);
895 } else {
896 let mut rt = crate::mcp::McpRuntime::new("/mcp");
897 rt.endpoint_layer = Some(applier);
898 self.mcp = Some(rt);
899 }
900 self
901 }
902
903 #[must_use]
935 pub fn exception_filter(mut self, filter: impl ExceptionFilter) -> Self {
936 self.exception_filters.push(Arc::new(filter));
937 self
938 }
939
940 #[must_use]
979 #[cfg(feature = "maud")]
980 pub fn error_pages(mut self, renderer: impl ErrorPageRenderer) -> Self {
981 self.error_page_renderer = Some(Arc::new(renderer));
982 self
983 }
984
985 #[must_use]
1008 pub fn scoped<L>(mut self, prefix: &str, layer: L, routes: Vec<Route>) -> Self
1009 where
1010 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
1011 L::Service: tower::Service<
1012 axum::http::Request<axum::body::Body>,
1013 Response = axum::http::Response<axum::body::Body>,
1014 Error = std::convert::Infallible,
1015 > + Clone
1016 + Send
1017 + Sync
1018 + 'static,
1019 <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
1020 Send + 'static,
1021 {
1022 let source = self
1023 .current_plugin
1024 .as_ref()
1025 .map_or(crate::route_listing::RouteSource::User, |name| {
1026 crate::route_listing::RouteSource::Plugin(name.clone())
1027 });
1028 self.scoped_groups.push(ScopedGroup {
1029 prefix: prefix.to_owned(),
1030 routes,
1031 source,
1032 apply_layer: Box::new(move |router| router.layer(layer)),
1033 });
1034 self
1035 }
1036
1037 #[must_use]
1109 pub fn layer<L: IntoAppLayer>(mut self, layer: L) -> Self {
1110 self.custom_layers.push(CustomLayerRegistration {
1111 type_id: TypeId::of::<L>(),
1112 type_name: std::any::type_name::<L>(),
1113 apply: Box::new(move |router| layer.apply_to(router)),
1114 });
1115 self
1116 }
1117
1118 #[must_use]
1123 pub fn has_layer<L: 'static>(&self) -> bool {
1124 let layer_type = TypeId::of::<L>();
1125 self.custom_layers
1126 .iter()
1127 .any(|registered| registered.type_id == layer_type)
1128 }
1129
1130 #[must_use]
1156 pub const fn idempotent(mut self) -> Self {
1157 self.idempotency_enabled = true;
1158 self
1159 }
1160
1161 #[must_use]
1166 pub fn get_layer_types(&self) -> Vec<TypeId> {
1167 self.custom_layers
1168 .iter()
1169 .map(|registered| registered.type_id)
1170 .collect()
1171 }
1172
1173 #[must_use]
1260 pub fn static_gate<L: IntoAppLayer>(mut self, layer: L) -> Self {
1261 self.static_gate_layers.push(CustomLayerRegistration {
1262 type_id: TypeId::of::<L>(),
1263 type_name: std::any::type_name::<L>(),
1264 apply: Box::new(move |router| layer.apply_to(router)),
1265 });
1266 self
1267 }
1268
1269 #[must_use]
1274 pub fn has_static_gate<L: 'static>(&self) -> bool {
1275 let layer_type = TypeId::of::<L>();
1276 self.static_gate_layers
1277 .iter()
1278 .any(|registered| registered.type_id == layer_type)
1279 }
1280
1281 #[must_use]
1288 pub fn get_static_gate_types(&self) -> Vec<TypeId> {
1289 self.static_gate_layers
1290 .iter()
1291 .map(|registered| registered.type_id)
1292 .collect()
1293 }
1294
1295 #[must_use]
1338 pub fn merge(mut self, router: axum::Router<AppState>) -> Self {
1339 self.merge_routers.push(router);
1340 self
1341 }
1342
1343 #[must_use]
1380 pub fn nest(mut self, path: &str, router: axum::Router<AppState>) -> Self {
1381 self.nest_routers.push((path.to_owned(), router));
1382 self
1383 }
1384
1385 #[must_use]
1405 pub fn declare_plugin_routes(
1406 mut self,
1407 routes: impl IntoIterator<Item = crate::route_listing::RouteInfo>,
1408 ) -> Self {
1409 let source = self
1410 .current_plugin
1411 .as_deref()
1412 .map_or(crate::route_listing::RouteSource::User, |name| {
1413 crate::route_listing::RouteSource::Plugin(name.to_owned())
1414 });
1415 for mut route in routes {
1416 route.source = source.clone();
1417 self.declared_routes.push(route);
1418 }
1419 self
1420 }
1421
1422 #[must_use]
1428 pub fn on_startup<F, Fut>(mut self, hook: F) -> Self
1429 where
1430 F: Fn(AppState) -> Fut + Send + Sync + 'static,
1431 Fut: Future<Output = crate::AutumnResult<()>> + Send + 'static,
1432 {
1433 self.startup_hooks
1434 .push(Box::new(move |state| Box::pin(hook(state))));
1435 self
1436 }
1437
1438 #[must_use]
1441 pub fn state_initializer<F>(mut self, initializer: F) -> Self
1442 where
1443 F: FnOnce(&AppState) + Send + 'static,
1444 {
1445 self.state_initializers.push(Box::new(initializer));
1446 self
1447 }
1448
1449 #[must_use]
1454 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
1455 where
1456 F: Fn() -> Fut + Send + Sync + 'static,
1457 Fut: Future<Output = ()> + Send + 'static,
1458 {
1459 self.shutdown_hooks.push(Box::new(move || Box::pin(hook())));
1460 self
1461 }
1462
1463 #[must_use]
1465 pub fn api_version(mut self, version: ApiVersion) -> Self {
1466 if let Some(pos) = self
1467 .api_versions
1468 .iter()
1469 .position(|v| v.version == version.version)
1470 {
1471 self.api_versions[pos] = version;
1472 } else {
1473 self.api_versions.push(version);
1474 }
1475 self
1476 }
1477
1478 #[must_use]
1480 pub fn api_versions(mut self, versions: impl IntoIterator<Item = ApiVersion>) -> Self {
1481 for version in versions {
1482 if let Some(pos) = self
1483 .api_versions
1484 .iter()
1485 .position(|v| v.version == version.version)
1486 {
1487 self.api_versions[pos] = version;
1488 } else {
1489 self.api_versions.push(version);
1490 }
1491 }
1492 self
1493 }
1494
1495 #[must_use]
1500 pub fn with_extension<T>(mut self, value: T) -> Self
1501 where
1502 T: Any + Send + 'static,
1503 {
1504 self.extensions.insert(TypeId::of::<T>(), Box::new(value));
1505 self
1506 }
1507
1508 #[must_use]
1516 pub fn update_extension<T, Init, Update>(mut self, init: Init, update: Update) -> Self
1517 where
1518 T: Any + Send + 'static,
1519 Init: FnOnce() -> T,
1520 Update: FnOnce(&mut T),
1521 {
1522 let type_id = TypeId::of::<T>();
1523 let entry = self
1524 .extensions
1525 .entry(type_id)
1526 .or_insert_with(|| Box::new(init()));
1527 let typed = entry
1528 .downcast_mut::<T>()
1529 .expect("extension type map corrupted");
1530 update(typed);
1531 self
1532 }
1533
1534 #[must_use]
1536 pub fn extension<T>(&self) -> Option<&T>
1537 where
1538 T: Any + Send + 'static,
1539 {
1540 self.extensions.get(&TypeId::of::<T>())?.downcast_ref::<T>()
1541 }
1542
1543 #[cfg(feature = "mail")]
1544 #[must_use]
1545 pub fn with_mail_interceptor(
1546 mut self,
1547 interceptor: impl crate::interceptor::MailInterceptor,
1548 ) -> Self {
1549 self.mail_interceptor = Some(Arc::new(interceptor));
1550 self
1551 }
1552
1553 #[must_use]
1554 pub fn with_job_interceptor(
1555 mut self,
1556 interceptor: impl crate::interceptor::JobInterceptor,
1557 ) -> Self {
1558 self.job_interceptor = Some(Arc::new(interceptor));
1559 self
1560 }
1561
1562 #[cfg(feature = "db")]
1563 #[must_use]
1564 pub fn with_db_interceptor(
1565 mut self,
1566 interceptor: impl crate::interceptor::DbConnectionInterceptor,
1567 ) -> Self {
1568 self.db_interceptor = Some(Arc::new(interceptor));
1569 self
1570 }
1571
1572 #[cfg(feature = "ws")]
1573 #[must_use]
1574 pub fn with_channels_interceptor(
1575 mut self,
1576 interceptor: impl crate::interceptor::ChannelsInterceptor,
1577 ) -> Self {
1578 self.channels_interceptor = Some(Arc::new(interceptor));
1579 self
1580 }
1581
1582 #[cfg(feature = "oauth2")]
1583 #[must_use]
1584 pub fn with_http_interceptor(
1585 mut self,
1586 interceptor: impl crate::interceptor::HttpInterceptor,
1587 ) -> Self {
1588 self.http_interceptor = Some(Arc::new(interceptor));
1589 self
1590 }
1591
1592 #[cfg(feature = "i18n")]
1600 #[must_use]
1601 pub fn i18n(mut self, bundle: crate::i18n::Bundle) -> Self {
1602 self.i18n_bundle = Some(Arc::new(bundle));
1603 self.i18n_auto_load = false;
1604 self
1605 }
1606
1607 #[cfg(feature = "i18n")]
1643 #[must_use]
1644 pub fn i18n_auto(mut self) -> Self {
1645 self.i18n_bundle = None;
1646 self.i18n_auto_load = true;
1647 self
1648 }
1649
1650 #[must_use]
1666 pub fn with_config_loader<L>(mut self, loader: L) -> Self
1667 where
1668 L: crate::config::ConfigLoader,
1669 {
1670 if self.config_loader_factory.is_some() {
1671 tracing::warn!(
1672 "config loader replaced; the previously-installed loader was overwritten"
1673 );
1674 }
1675 self.config_loader_factory = Some(Box::new(move || {
1676 Box::pin(async move { loader.load().await })
1677 }));
1678 self
1679 }
1680
1681 #[cfg(feature = "db")]
1689 #[must_use]
1690 pub fn with_pool_provider<P>(mut self, provider: P) -> Self
1691 where
1692 P: crate::db::DatabasePoolProvider,
1693 {
1694 if self.pool_provider_factory.is_some() {
1695 tracing::warn!(
1696 "database pool provider replaced; the previously-installed provider was overwritten"
1697 );
1698 }
1699 let provider = Arc::new(provider);
1702 let shard_provider = Arc::clone(&provider);
1703 self.pool_provider_factory =
1704 Some(Box::new(move |config: crate::config::DatabaseConfig| {
1705 Box::pin(async move { provider.create_topology(&config).await })
1706 }));
1707 self.shard_provider_factory =
1708 Some(Box::new(move |config: crate::config::DatabaseConfig| {
1709 Box::pin(async move {
1710 let mut topologies = Vec::with_capacity(config.shards.len());
1711 for shard in &config.shards {
1712 topologies
1713 .push(shard_provider.create_shard_topology(shard, &config).await?);
1714 }
1715 Ok(topologies)
1716 })
1717 }));
1718 self
1719 }
1720
1721 #[cfg(feature = "db")]
1732 #[must_use]
1733 pub fn with_shard_router<R>(mut self, router: R) -> Self
1734 where
1735 R: crate::sharding::ShardRouter,
1736 {
1737 if self.shard_router.is_some() {
1738 tracing::warn!(
1739 "shard router replaced; the previously-installed router was overwritten"
1740 );
1741 }
1742 self.shard_router = Some(Arc::new(router));
1743 self
1744 }
1745
1746 #[cfg(feature = "db")]
1757 #[must_use]
1758 pub const fn with_directory_shard_router(mut self) -> Self {
1759 self.directory_shard_router = true;
1760 self
1761 }
1762
1763 #[must_use]
1770 pub fn with_telemetry_provider<T>(mut self, provider: T) -> Self
1771 where
1772 T: crate::telemetry::TelemetryProvider,
1773 {
1774 if self.telemetry_provider.is_some() {
1775 tracing::warn!(
1776 "telemetry provider replaced; the previously-installed provider was overwritten"
1777 );
1778 }
1779 self.telemetry_provider = Some(Box::new(provider));
1780 self
1781 }
1782
1783 #[must_use]
1790 pub fn with_session_store<S>(mut self, store: S) -> Self
1791 where
1792 S: crate::session::SessionStore,
1793 {
1794 if self.session_store.is_some() {
1795 tracing::warn!(
1796 "session store replaced; the previously-installed store was overwritten"
1797 );
1798 }
1799 self.session_store = Some(Arc::new(store));
1800 self
1801 }
1802
1803 #[cfg(feature = "ws")]
1810 #[must_use]
1811 pub fn with_channels_backend<B>(mut self, backend: B) -> Self
1812 where
1813 B: crate::channels::ChannelsBackend,
1814 {
1815 if self.channels_backend.is_some() {
1816 tracing::warn!(
1817 "channels backend replaced; the previously-installed backend was overwritten"
1818 );
1819 }
1820 self.channels_backend = Some(Arc::new(backend));
1821 self
1822 }
1823
1824 #[cfg(feature = "storage")]
1858 #[must_use]
1859 pub fn with_blob_store<B>(mut self, store: B) -> Self
1860 where
1861 B: crate::storage::BlobStore,
1862 {
1863 if self.blob_store.is_some() {
1864 tracing::warn!("blob store replaced; the previously-installed store was overwritten");
1865 }
1866 self.blob_store = Some(std::sync::Arc::new(store));
1867 self
1868 }
1869
1870 #[must_use]
1889 pub fn with_cache_backend<C: crate::cache::Cache>(mut self, cache: C) -> Self {
1890 if self.cache_backend.is_some() {
1891 tracing::warn!(
1892 "cache backend replaced; the previously-installed backend was overwritten"
1893 );
1894 }
1895 self.cache_backend = Some(Arc::new(cache) as Arc<dyn crate::cache::Cache>);
1896 self
1897 }
1898
1899 #[cfg(feature = "reporting")]
1934 #[must_use]
1935 pub fn with_error_reporter<R: crate::reporting::ErrorReporter>(mut self, reporter: R) -> Self {
1936 self.error_reporters
1937 .push(Arc::new(reporter) as Arc<dyn crate::reporting::ErrorReporter>);
1938 self
1939 }
1940
1941 #[must_use]
1977 pub fn with_alert_channel<C: crate::alerts::AlertChannel>(mut self, channel: C) -> Self {
1978 self.alert_channels
1979 .push(Arc::new(channel) as Arc<dyn crate::alerts::AlertChannel>);
1980 self
1981 }
1982
1983 #[must_use]
2027 pub fn with_flag_store<S>(self, store: S) -> Self
2028 where
2029 S: crate::feature_flags::FlagStore,
2030 {
2031 let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>);
2032 self.state_initializer(move |state| {
2033 state.insert_extension(service);
2034 })
2035 }
2036
2037 #[must_use]
2059 pub fn with_flag_store_and_resolver<S>(
2060 self,
2061 store: S,
2062 resolver: crate::feature_flags::GroupResolver,
2063 ) -> Self
2064 where
2065 S: crate::feature_flags::FlagStore,
2066 {
2067 let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>)
2068 .with_group_resolver(resolver);
2069 self.state_initializer(move |state| {
2070 state.insert_extension(service);
2071 })
2072 }
2073
2074 #[must_use]
2111 pub fn with_experiment_store<S>(self, store: S) -> Self
2112 where
2113 S: crate::experiments::ExperimentStore,
2114 {
2115 let service = crate::experiments::ExperimentService::new(Arc::new(store) as Arc<_>);
2116 self.state_initializer(move |state| {
2117 state.insert_extension(service);
2118 })
2119 }
2120
2121 #[must_use]
2143 pub fn with_experiment_store_and_sink<S>(
2144 self,
2145 store: S,
2146 sink: Arc<dyn crate::experiments::ExposureSink>,
2147 ) -> Self
2148 where
2149 S: crate::experiments::ExperimentStore,
2150 {
2151 let service = crate::experiments::ExperimentService::new(Arc::new(store) as Arc<_>)
2152 .with_exposure_sink(sink);
2153 self.state_initializer(move |state| {
2154 state.insert_extension(service);
2155 })
2156 }
2157
2158 #[cfg(feature = "mail")]
2169 #[must_use]
2170 pub fn with_mail_delivery_queue(
2171 mut self,
2172 queue: impl crate::mail::MailDeliveryQueue + 'static,
2173 ) -> Self {
2174 let arc: Arc<dyn crate::mail::MailDeliveryQueue> = Arc::new(queue);
2175 self.mail_delivery_queue_factory = Some(Box::new(move |_state| Ok(arc)));
2176 self
2177 }
2178
2179 #[cfg(feature = "mail")]
2189 #[must_use]
2190 pub fn with_mail_delivery_queue_factory<F, Q>(mut self, factory: F) -> Self
2191 where
2192 F: FnOnce(&AppState) -> crate::AutumnResult<Q> + Send + 'static,
2193 Q: crate::mail::MailDeliveryQueue + 'static,
2194 {
2195 self.mail_delivery_queue_factory = Some(Box::new(move |state| {
2196 factory(state).map(|q| Arc::new(q) as Arc<dyn crate::mail::MailDeliveryQueue>)
2197 }));
2198 self
2199 }
2200
2201 #[cfg(feature = "mail")]
2210 #[must_use]
2211 pub fn with_suppression_store(
2212 mut self,
2213 store: impl crate::mail::SuppressionStore + 'static,
2214 ) -> Self {
2215 self.suppression_store = Some(crate::mail::SuppressionStoreHandle::new(store));
2216 self
2217 }
2218
2219 #[cfg(feature = "mail")]
2230 #[must_use]
2231 pub fn with_mail_suppression_store(
2232 mut self,
2233 store: impl crate::mail::suppression::SuppressionStore + 'static,
2234 ) -> Self {
2235 self.mail_suppression_store =
2236 Some(crate::mail::suppression::SuppressionStoreHandle::new(store));
2237 self
2238 }
2239
2240 #[cfg(feature = "mail")]
2249 #[must_use]
2250 pub const fn mount_unsubscribe_endpoint(mut self) -> Self {
2251 self.mount_unsubscribe_endpoint = true;
2252 self
2253 }
2254
2255 #[cfg(feature = "inbound-mail")]
2285 #[must_use]
2286 pub fn inbound_mail_router(mut self, router: crate::inbound_mail::InboundMailRouter) -> Self {
2287 self.inbound_mail_router = Some(Arc::new(router));
2288 self
2289 }
2290
2291 #[cfg(feature = "mail")]
2295 #[must_use]
2296 pub fn mail_previews(
2297 mut self,
2298 previews: impl IntoIterator<Item = crate::mail::MailPreview>,
2299 ) -> Self {
2300 self.mail_previews.extend(previews);
2301 self
2302 }
2303
2304 #[cfg(feature = "maud")]
2313 #[must_use]
2314 pub fn with_story_gallery(mut self, gallery: crate::stories::StoryGallery) -> Self {
2315 self.story_gallery = Some(gallery);
2316 self
2317 }
2318
2319 #[must_use]
2324 pub fn with_audit_sink<S>(mut self, sink: S) -> Self
2325 where
2326 S: crate::audit::AuditSink,
2327 {
2328 let logger = self
2329 .audit_logger
2330 .take()
2331 .map_or_else(crate::audit::AuditLogger::new, |logger| (*logger).clone())
2332 .with_sink(Arc::new(sink));
2333 self.audit_logger = Some(Arc::new(logger));
2334 self
2335 }
2336
2337 #[must_use]
2360 pub fn policy<R, P>(mut self, policy: P) -> Self
2361 where
2362 R: Send + Sync + 'static,
2363 P: crate::authorization::Policy<R>,
2364 {
2365 self.policy_registrations.push(Box::new(move |registry| {
2366 registry.register_policy::<R, _>(policy);
2367 }));
2368 self
2369 }
2370
2371 #[must_use]
2379 pub fn scope<R, S>(mut self, scope: S) -> Self
2380 where
2381 R: Send + Sync + 'static,
2382 S: crate::authorization::Scope<R>,
2383 {
2384 self.policy_registrations.push(Box::new(move |registry| {
2385 registry.register_scope::<R, _>(scope);
2386 }));
2387 self
2388 }
2389
2390 #[must_use]
2398 #[track_caller]
2399 pub fn plugin<P>(mut self, plugin: P) -> Self
2400 where
2401 P: crate::plugin::Plugin,
2402 {
2403 let name = plugin.name();
2404 if self.registered_plugins.contains(name.as_ref()) {
2405 tracing::warn!(
2406 plugin = name.as_ref(),
2407 "plugin already registered; skipping duplicate"
2408 );
2409 return self;
2410 }
2411 let name_str = name.into_owned();
2412 self.registered_plugins.insert(name_str.clone());
2413 let outer_plugin = self.current_plugin.replace(name_str);
2416 let mut result = plugin.build(self);
2417 result.current_plugin = outer_plugin;
2418 result
2419 }
2420
2421 #[must_use]
2424 pub fn plugins<P>(self, plugins: P) -> Self
2425 where
2426 P: crate::plugin::Plugins,
2427 {
2428 plugins.apply(self)
2429 }
2430
2431 #[must_use]
2434 pub fn has_plugin(&self, name: &str) -> bool {
2435 self.registered_plugins.contains(name)
2436 }
2437
2438 #[must_use]
2480 pub fn config_section(mut self, root: impl Into<String>) -> Self {
2481 self.plugin_config_roots.insert(root.into());
2482 self
2483 }
2484
2485 #[must_use]
2491 pub fn has_config_section(&self, root: &str) -> bool {
2492 self.plugin_config_roots.contains(root)
2493 }
2494
2495 #[must_use]
2531 pub fn metrics_source(
2532 mut self,
2533 name: impl Into<String>,
2534 source: Arc<dyn crate::actuator::MetricsSource>,
2535 ) -> Self {
2536 let name = name.into();
2537 if self.metrics_sources.iter().any(|(n, _)| n == &name) {
2538 tracing::warn!(
2539 source_name = %name,
2540 "MetricsSource '{}' is already registered; skipping duplicate",
2541 name
2542 );
2543 return self;
2544 }
2545 self.metrics_sources.push((name, source));
2546 self
2547 }
2548
2549 #[must_use]
2573 pub fn health_indicator(
2574 mut self,
2575 name: impl Into<String>,
2576 indicator: Arc<dyn crate::actuator::HealthIndicator>,
2577 ) -> Self {
2578 let name = name.into();
2579 #[cfg(feature = "db")]
2585 if name == "db" || name.starts_with("db:shard:") {
2586 tracing::warn!(
2587 indicator_name = %name,
2588 "\"db\" and \"db:shard:*\" are reserved built-in health indicator names; \
2589 registration skipped. Use a different name for your custom indicator."
2590 );
2591 return self;
2592 }
2593 if self.health_indicators.iter().any(|(n, _, _)| n == &name) {
2594 tracing::warn!(
2595 indicator_name = %name,
2596 "HealthIndicator '{}' is already registered; skipping duplicate",
2597 name
2598 );
2599 return self;
2600 }
2601 let group = indicator.group();
2602 self.health_indicators.push((name, group, indicator));
2603 self
2604 }
2605
2606 #[cfg(feature = "db")]
2633 #[must_use]
2634 pub fn migrations(mut self, migrations: migrate::EmbeddedMigrations) -> Self {
2635 self.migrations.push(migrations);
2636 self
2637 }
2638
2639 #[cfg(feature = "embed-assets")]
2661 #[must_use]
2662 pub const fn embedded_static(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
2663 self.embedded_static = Some(crate::assets::EmbeddedStaticDir(dir));
2664 self
2665 }
2666
2667 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2683 #[must_use]
2684 pub const fn embedded_locales(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
2685 self.embedded_locales = Some(dir);
2686 self
2687 }
2688
2689 #[allow(clippy::too_many_lines)]
2709 #[allow(clippy::cognitive_complexity)]
2710 pub async fn run(self) {
2711 if is_static_build_mode() {
2715 self.run_build_mode().await;
2716 return;
2717 }
2718
2719 if is_dump_routes_mode() {
2724 self.run_dump_routes_mode().await;
2725 return;
2726 }
2727
2728 if is_dump_jobs_mode() {
2734 self.run_dump_jobs_mode().await;
2735 return;
2736 }
2737
2738 if is_list_one_off_tasks_mode() {
2739 self.run_list_one_off_tasks_mode();
2740 return;
2741 }
2742
2743 if let Some(task_name) = one_off_task_name_from_env() {
2744 self.run_one_off_task_mode(task_name).await;
2745 return;
2746 }
2747
2748 if is_migrate_only_mode() {
2757 self.run_migrate_only_mode().await;
2758 return;
2759 }
2760
2761 let Self {
2762 routes,
2763 api_versions,
2764 route_sources: _,
2765 current_plugin: _,
2766 tasks,
2767 one_off_tasks: _,
2768 mut jobs,
2769 listeners,
2770 static_metas,
2771 exception_filters,
2772 scoped_groups,
2773 merge_routers,
2774 nest_routers,
2775 custom_layers,
2776 static_gate_layers,
2777 startup_hooks,
2778 state_initializers,
2779 shutdown_hooks,
2780 extensions: _,
2781 registered_plugins: _,
2782 plugin_config_roots,
2783 #[cfg(feature = "maud")]
2784 error_page_renderer,
2785 #[cfg(feature = "db")]
2786 migrations,
2787 config_loader_factory,
2788 #[cfg(feature = "db")]
2789 pool_provider_factory,
2790 #[cfg(feature = "db")]
2791 shard_provider_factory,
2792 #[cfg(feature = "db")]
2793 shard_router,
2794 #[cfg(feature = "db")]
2795 directory_shard_router,
2796 telemetry_provider,
2797 session_store,
2798 #[cfg(feature = "ws")]
2799 channels_backend,
2800 #[cfg(feature = "storage")]
2801 blob_store,
2802 cache_backend,
2803 #[cfg(feature = "reporting")]
2804 error_reporters,
2805 alert_channels,
2806 #[cfg(feature = "openapi")]
2807 openapi,
2808 #[cfg(feature = "mcp")]
2809 mcp,
2810 audit_logger,
2811 #[cfg(feature = "i18n")]
2812 i18n_bundle,
2813 #[cfg(feature = "i18n")]
2814 i18n_auto_load,
2815 #[cfg(feature = "embed-assets")]
2816 embedded_static,
2817 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2818 embedded_locales,
2819 policy_registrations,
2820 #[cfg(feature = "mail")]
2821 mail_delivery_queue_factory,
2822 #[cfg(feature = "mail")]
2823 suppression_store,
2824 #[cfg(feature = "mail")]
2825 mail_suppression_store,
2826 #[cfg(feature = "mail")]
2827 mount_unsubscribe_endpoint,
2828 #[cfg(feature = "mail")]
2829 mail_previews,
2830 #[cfg(feature = "maud")]
2831 story_gallery,
2832 declared_routes: _,
2833 idempotency_enabled,
2834 #[cfg(feature = "mail")]
2835 mail_interceptor,
2836 job_interceptor,
2837 #[cfg(feature = "db")]
2838 db_interceptor,
2839 #[cfg(feature = "ws")]
2840 channels_interceptor,
2841 #[cfg(feature = "oauth2")]
2842 http_interceptor,
2843 seo_sources,
2844 metrics_sources,
2845 health_indicators,
2846 #[cfg(feature = "inbound-mail")]
2847 inbound_mail_router,
2848 } = self;
2849
2850 let all_routes = routes;
2851
2852 let (mut config, telemetry_guard) = load_config_and_telemetry(
2854 config_loader_factory,
2855 telemetry_provider,
2856 plugin_config_roots,
2857 )
2858 .await;
2859
2860 let role = config.role;
2870 if crate::config::split_role_requires_durable_backend(role, &config.jobs.backend) {
2871 tracing::error!(
2872 role = role.as_str(),
2873 jobs_backend = %config.jobs.backend,
2874 "process role '{}' requires a durable jobs backend: backend '{}' is not \
2875 a recognized durable backend and falls through to the in-process 'local' \
2876 runtime, which cannot be shared across a split web/worker topology. \
2877 Set jobs.backend = \"postgres\" or \"redis\", or run the combined role.",
2878 role.as_str(),
2879 config.jobs.backend,
2880 );
2881 #[cfg(feature = "managed-pg")]
2882 crate::managed_pg::emergency_stop_async().await;
2883 std::process::exit(1);
2884 }
2885
2886 #[cfg(feature = "mail")]
2887 if mount_unsubscribe_endpoint {
2888 config.mail.mount_unsubscribe_endpoint = true;
2889 }
2890
2891 if idempotency_enabled {
2897 let env_disabled = std::env::var("AUTUMN_IDEMPOTENCY__ENABLED")
2898 .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "false" | "0" | "no" | "off"));
2899 if !env_disabled && config.idempotency.enabled != Some(false) {
2902 config.idempotency.enabled = Some(true);
2903 }
2904 }
2905
2906 #[cfg(feature = "embed-assets")]
2911 register_embedded_static_dir(embedded_static);
2912
2913 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2914 let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
2915
2916 #[cfg(feature = "i18n")]
2917 let i18n_bundle =
2918 resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
2919
2920 assert!(
2922 !all_routes.is_empty(),
2923 "No routes registered. Did you forget to call .routes()?"
2924 );
2925
2926 let profile_display = config.profile.as_deref().unwrap_or("none");
2928 tracing::info!(
2929 version = env!("CARGO_PKG_VERSION"),
2930 profile = profile_display,
2931 "Autumn starting"
2932 );
2933
2934 let show_config = std::env::var("AUTUMN_SHOW_CONFIG").as_deref() == Ok("1");
2936 if show_config {
2937 log_startup_transparency(&all_routes, &tasks, &scoped_groups, &config);
2938 }
2939
2940 fail_fast_on_invalid_session_config(&config, session_store.is_some());
2944
2945 fail_fast_on_invalid_signing_secret(&config);
2948 fail_fast_on_missing_encryption_keys(&config);
2949 fail_fast_on_invalid_trusted_hosts(&config);
2950
2951 fail_fast_on_invalid_webhook_config(&config);
2955
2956 fail_fast_on_invalid_idempotency_config(&config);
2958
2959 #[cfg(feature = "storage")]
2968 let storage_bootstrap = blob_store.map_or_else(
2969 || preflight_storage(&config),
2970 |store| {
2971 Some(StorageBootstrap {
2972 store,
2973 serving: None,
2974 })
2975 },
2976 );
2977
2978 #[cfg(feature = "db")]
2980 let database = setup_database(
2981 &config,
2982 migrations,
2983 pool_provider_factory,
2984 shard_provider_factory,
2985 shard_router,
2986 directory_shard_router,
2987 RepositoryCommitHookQueueMigrationMode::Runtime,
2988 )
2989 .await
2990 .unwrap_or_else(|e| {
2991 tracing::error!("{e}");
2992 std::process::exit(1);
2993 });
2994 #[cfg(feature = "db")]
2995 let pool = database.topology;
2996 #[cfg(feature = "db")]
2997 let shards = database.shards;
2998 #[cfg(feature = "db")]
2999 let replica_readiness = database.replica_readiness;
3000 #[cfg(feature = "db")]
3001 let replica_migration_check = database.replica_migration_check;
3002
3003 #[cfg(feature = "db")]
3004 if pool.is_some() || shards.is_some() {
3005 let shard_max_connections = shards
3008 .as_ref()
3009 .map_or(0, crate::sharding::ShardSet::total_max_connections);
3010 let control_max_connections = pool.as_ref().map_or(0, |topology| {
3011 topology.primary().status().max_size
3012 + topology.replica().map_or(0, |p| p.status().max_size)
3013 });
3014 let total_max_connections = control_max_connections + shard_max_connections;
3015 tracing::info!(
3016 primary_max_connections = config.database.effective_primary_pool_size(),
3017 replica_configured = config.database.replica_url.is_some(),
3018 replica_max_connections = config.database.effective_replica_pool_size(),
3019 shard_count = shards.as_ref().map_or(0, crate::sharding::ShardSet::len),
3020 total_max_connections,
3021 "Database topology configured"
3022 );
3023 let warn_threshold = config.database.max_connections_warn_threshold;
3026 if crate::config::should_warn_total_connections(total_max_connections, warn_threshold) {
3027 tracing::warn!(
3028 total_max_connections,
3029 warn_threshold,
3030 "Aggregate database connection count is high: the control \
3031 topology and all shard pools together may open \
3032 {total_max_connections} connections (warn threshold \
3033 {warn_threshold}). Ensure each Postgres server's \
3034 max_connections (plus headroom for migrations and \
3035 psql) exceeds the pools that target it, or lower \
3036 database.pool_size. Set \
3037 database.max_connections_warn_threshold = 0 to silence."
3038 );
3039 }
3040 } else {
3041 tracing::info!("Database not configured");
3042 }
3043
3044 validate_repository_api_policies(&all_routes, &scoped_groups, &config);
3052
3053 let mut state = build_state(
3055 &config,
3056 #[cfg(feature = "db")]
3057 pool.as_ref(),
3058 #[cfg(feature = "db")]
3059 shards,
3060 #[cfg(feature = "ws")]
3061 channels_backend,
3062 );
3063
3064 if let Some(buf) = telemetry_guard.log_buffer.clone() {
3067 state.insert_extension(buf);
3068 }
3069 if let Some(handle) = telemetry_guard.filter_reload.clone() {
3073 state.log_levels().attach_reload_handle(handle);
3074 }
3075
3076 let maintenance_state = crate::maintenance::MaintenanceState::new();
3078 let flag_path = std::path::Path::new(crate::maintenance::MAINTENANCE_FLAG_FILE);
3079 if let Ok(Some(cfg)) = crate::maintenance::MaintenanceState::load_from_file(flag_path) {
3080 maintenance_state.enable(cfg);
3081 }
3082 state.insert_extension(maintenance_state.clone());
3083
3084 let poller_state = maintenance_state.clone();
3085 tokio::spawn(async move {
3086 let path = std::path::Path::new(crate::maintenance::MAINTENANCE_FLAG_FILE);
3087 let interval = std::time::Duration::from_millis(500);
3088 loop {
3089 let load_res = tokio::task::spawn_blocking(move || {
3090 crate::maintenance::MaintenanceState::load_from_file(path)
3091 })
3092 .await;
3093
3094 match load_res {
3095 Ok(Ok(Some(cfg))) => {
3096 if poller_state.get() != Some(cfg.clone()) {
3097 poller_state.enable(cfg);
3098 }
3099 }
3100 Ok(Ok(None)) => {
3101 if poller_state.is_active() {
3102 poller_state.disable();
3103 }
3104 }
3105 Ok(Err(e)) => {
3106 tracing::error!(error = %e, "failed to load maintenance flag file");
3107 }
3108 Err(e) => {
3109 tracing::error!(error = %e, "maintenance poller task panicked");
3110 }
3111 }
3112 tokio::time::sleep(interval).await;
3113 }
3114 });
3115
3116 let canary_state = crate::canary::CanaryState::from_env();
3120 if canary_state.is_canary() {
3121 tracing::info!(
3122 version = canary_state.version(),
3123 "canary: replica labelled as canary cohort"
3124 );
3125 }
3126 state.insert_extension(canary_state);
3127
3128 if crate::canary::CanaryState::rollback_flag_present(std::path::Path::new(
3133 crate::canary::CANARY_ROLLBACK_FLAG_FILE,
3134 )) {
3135 tracing::warn!(
3136 "canary: rollback flag present at startup; /ready will report draining until \
3137 the flag is cleared (`autumn canary promote`)"
3138 );
3139 state.begin_shutdown();
3140 }
3141
3142 #[cfg(feature = "mail")]
3143 if let Some(interceptor) = mail_interceptor {
3144 state.insert_extension(interceptor);
3145 }
3146 if let Some(interceptor) = job_interceptor {
3147 state.insert_extension(interceptor);
3148 }
3149 #[cfg(feature = "db")]
3150 if let Some(interceptor) = db_interceptor {
3151 state.insert_extension(interceptor);
3152 }
3153 #[cfg(feature = "ws")]
3154 if let Some(interceptor) = channels_interceptor {
3155 state.insert_extension(interceptor.clone());
3156 state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
3157 crate::channels::InterceptedChannelsBackend::new(
3158 state.channels.backend().clone(),
3159 vec![interceptor],
3160 ),
3161 ));
3162 #[cfg(feature = "presence")]
3163 {
3164 state.presence = crate::presence::Presence::new(state.channels.clone());
3165 }
3166 }
3167 #[cfg(feature = "oauth2")]
3168 if let Some(interceptor) = http_interceptor {
3169 state.insert_extension(interceptor);
3170 }
3171
3172 for (name, source) in metrics_sources {
3176 if let Err(e) = state.metrics_source_registry.register(name, source) {
3177 tracing::warn!("{e}");
3178 }
3179 }
3180
3181 for (name, group, indicator) in health_indicators {
3183 if let Err(e) = state
3184 .health_indicator_registry
3185 .register(name, group, indicator)
3186 {
3187 tracing::warn!("{e}");
3188 }
3189 }
3190
3191 #[cfg(feature = "acme")]
3196 let acme_status: Option<crate::acme::renewal::AcmeStatus> = if let Some(acme_cfg) =
3197 config.server.tls.as_ref().and_then(|t| t.acme.as_ref())
3198 {
3199 let status = crate::acme::renewal::AcmeStatus::new();
3200 let indicator = std::sync::Arc::new(crate::acme::renewal::AcmeHealthIndicator::new(
3201 status.clone(),
3202 acme_cfg.renew_before_days,
3203 ));
3204 if let Err(e) = state.health_indicator_registry.register(
3205 "acme",
3206 crate::actuator::IndicatorGroup::HealthOnly,
3207 indicator,
3208 ) {
3209 tracing::warn!("{e}");
3210 }
3211 Some(status)
3212 } else {
3213 None
3214 };
3215
3216 #[cfg(feature = "db")]
3217 configure_replica_migration_check(&state, replica_migration_check);
3218 #[cfg(feature = "db")]
3219 apply_replica_migration_readiness(&state, replica_readiness);
3220 if let Some(cache) = cache_backend {
3221 crate::cache::set_global_cache(cache.clone());
3222 state.shared_cache = Some(cache);
3223 } else {
3224 crate::cache::clear_global_cache();
3225 }
3226 state.insert_extension(RegisteredApiVersions(api_versions));
3227
3228 #[cfg(all(feature = "acme", feature = "reporting"))]
3233 let acme_reporters = error_reporters.clone();
3234
3235 #[cfg(feature = "reporting")]
3239 if !error_reporters.is_empty() {
3240 state.insert_extension(crate::reporting::RegisteredReporters(error_reporters));
3241 }
3242 for register in policy_registrations {
3247 register(state.policy_registry());
3248 }
3249 validate_repository_policies_registered(&all_routes, &scoped_groups, &state, &config);
3255 #[cfg(feature = "mail")]
3256 if let Some(handle) = suppression_store {
3257 state.insert_extension(handle);
3258 }
3259 #[cfg(feature = "mail")]
3260 if let Some(handle) = mail_suppression_store {
3261 state.insert_extension(handle);
3262 }
3263 #[cfg(feature = "mail")]
3264 crate::mail::install_mailer_with_factory(
3265 &state,
3266 &config.mail,
3267 mail_delivery_queue_factory,
3268 true,
3269 )
3270 .unwrap_or_else(|error| {
3271 tracing::error!(error = %error, "Failed to configure mailer");
3272 exit_stop_managed_pg();
3273 std::process::exit(1);
3274 });
3275 #[cfg(feature = "mail")]
3276 state.insert_extension(crate::mail::MailPreviewRegistry::new(mail_previews));
3277 #[cfg(feature = "maud")]
3278 install_story_registry(&state, story_gallery);
3279 crate::alerts::install_from_config(&state, &config.alerts, alert_channels);
3285 if let Some(logger) = audit_logger {
3286 state.insert_extension::<crate::audit::AuditLogger>((*logger).clone());
3287 }
3288 #[cfg(feature = "i18n")]
3289 let custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
3290
3291 #[cfg(feature = "storage")]
3295 let storage_router = storage_bootstrap.and_then(|b| b.install(&state));
3296 install_webhook_registry(&state, &config);
3297 run_state_initializers(state_initializers, &state);
3298 finalize_event_bus(listeners, &mut jobs, &state);
3299
3300 let env = crate::config::OsEnv;
3301 let dist_dir = project_dir("dist", &env);
3302 let dist_ref = if dist_dir.exists() {
3303 Some(dist_dir.as_path())
3304 } else {
3305 None
3306 };
3307 #[cfg_attr(
3308 not(any(feature = "storage", feature = "inbound-mail")),
3309 allow(unused_mut)
3310 )]
3311 let mut merge_routers = merge_routers;
3312 #[cfg(feature = "storage")]
3313 if let Some(router) = storage_router {
3314 merge_routers.push(router);
3315 }
3316
3317 if !seo_sources.is_empty() || crate::seo::has_seo_config(&config.seo) {
3320 let seo_cfg = &config.seo;
3321 let raw_profile = config.profile.as_deref().unwrap_or("dev");
3322 let profile = crate::seo::effective_seo_profile(raw_profile, seo_cfg.robots.allow_all);
3323 let static_paths: Vec<&str> = static_metas.iter().map(|m| m.path).collect();
3324 let (robots_body, sitemap_body) = crate::seo::assemble_seo_bodies(
3325 profile,
3326 seo_cfg.base_url.as_deref(),
3327 seo_cfg.robots.sitemap_url.as_deref(),
3328 &seo_cfg.robots.additional_rules,
3329 &seo_sources,
3330 &static_paths,
3331 )
3332 .await;
3333 let seo_router = crate::seo::build_seo_router_from_bodies(robots_body, sitemap_body);
3334 let is_seo_path = |p: &str| p == "/robots.txt" || p == "/sitemap.xml";
3335 let seo_collision = all_routes.iter().any(|r| is_seo_path(r.path))
3336 || static_metas.iter().any(|m| is_seo_path(m.path))
3337 || scoped_groups.iter().any(|g| {
3338 let prefix = g.prefix.trim_end_matches('/');
3339 g.routes
3340 .iter()
3341 .any(|r| is_seo_path(&format!("{prefix}{}", r.path)))
3342 });
3343 if seo_collision {
3344 tracing::warn!(
3345 "seo: /robots.txt or /sitemap.xml is already registered by the application; \
3346 skipping automatic SEO routes to prevent a startup panic"
3347 );
3348 } else {
3349 merge_routers.push(seo_router);
3350 }
3351 }
3352
3353 #[cfg(feature = "inbound-mail")]
3354 if let Some(ref im_router) = inbound_mail_router {
3355 let mut registered_inbound: std::collections::HashSet<String> =
3356 std::collections::HashSet::new();
3357 for (path, axum_router) in crate::inbound_mail::build_routes(im_router) {
3358 if all_routes
3363 .iter()
3364 .any(|r| r.method == http::Method::POST && r.path == path)
3365 || scoped_groups.iter().any(|g| {
3366 g.routes.iter().any(|r| {
3367 r.method == http::Method::POST
3368 && crate::router::join_nested_path(&g.prefix, r.path)
3369 == path.as_str()
3370 })
3371 })
3372 || nest_routers.iter().any(|(nest_path, _)| {
3373 let p = nest_path.as_str();
3374 path.as_str() == p
3375 || path.starts_with(p)
3376 && (p.ends_with('/') || path.as_bytes().get(p.len()) == Some(&b'/'))
3377 })
3378 {
3379 tracing::warn!(
3380 path = %path,
3381 "inbound_mail: skipping webhook route — a POST handler is \
3382 already registered at this path by the application"
3383 );
3384 continue;
3385 }
3386 if !registered_inbound.insert(path.clone()) {
3389 tracing::warn!(
3390 path = %path,
3391 "inbound_mail: skipping duplicate inbound webhook path"
3392 );
3393 continue;
3394 }
3395 config.security.csrf.exempt_paths.push(path.clone());
3399 config.security.captcha_exempt_paths.push(path);
3400 merge_routers.push(axum_router);
3401 }
3402 }
3403 let router_build = if role.serves_http() {
3410 crate::router::try_build_router_with_static_inner(
3411 all_routes,
3412 &config,
3413 state.clone(),
3414 dist_ref,
3415 crate::router::RouterContext {
3416 exception_filters,
3417 scoped_groups,
3418 merge_routers,
3419 nest_routers,
3420 custom_layers,
3421 static_gate_layers,
3422 #[cfg(feature = "maud")]
3423 error_page_renderer,
3424 session_store,
3425 #[cfg(feature = "openapi")]
3428 openapi: if config.openapi_runtime.enabled {
3429 openapi
3430 } else {
3431 None
3432 },
3433 #[cfg(feature = "mcp")]
3434 mcp,
3435 },
3436 )
3437 } else {
3438 crate::router::try_build_probe_only_router(&config, state.clone())
3439 };
3440 let router = router_build.unwrap_or_else(|error| {
3441 tracing::error!(error = %error, "Failed to build router");
3442 exit_stop_managed_pg();
3443 std::process::exit(1);
3444 });
3445
3446 if let Some(tls_cfg) = config.server.tls.as_ref() {
3463 if let Err(msg) = tls_cfg.validate() {
3468 tracing::error!("Invalid [server.tls] configuration: {msg}");
3469 #[cfg(feature = "managed-pg")]
3470 crate::managed_pg::emergency_stop_async().await;
3471 std::process::exit(1);
3472 }
3473 #[cfg(not(feature = "tls"))]
3474 {
3475 tracing::error!(
3476 "[server.tls] is configured but this binary was built without the `tls` \
3477 feature; rebuild with `--features tls`, or remove [server.tls] to serve \
3478 plain HTTP"
3479 );
3480 #[cfg(feature = "managed-pg")]
3481 crate::managed_pg::emergency_stop_async().await;
3482 std::process::exit(1);
3483 }
3484 #[cfg(all(feature = "tls", not(feature = "acme")))]
3488 if tls_cfg.acme.is_some() {
3489 tracing::error!(
3490 "[server.tls.acme] is configured but this binary was built without the \
3491 `acme` feature; rebuild with `--features acme`, or configure a static \
3492 cert_path/key_path instead"
3493 );
3494 #[cfg(feature = "managed-pg")]
3495 crate::managed_pg::emergency_stop_async().await;
3496 std::process::exit(1);
3497 }
3498 #[cfg(feature = "tls")]
3499 if config.server.unix_socket.is_some() {
3500 tracing::error!(
3501 "[server.tls] cannot be combined with server.unix_socket; direct TLS \
3502 terminates on host:port. Unset one of them"
3503 );
3504 #[cfg(feature = "managed-pg")]
3505 crate::managed_pg::emergency_stop_async().await;
3506 std::process::exit(1);
3507 }
3508 }
3509
3510 let server_shutdown = tokio_util::sync::CancellationToken::new();
3514
3515 #[cfg(feature = "tls")]
3518 let mut tls_reload_state: Option<TlsReloadState> = None;
3519
3520 #[cfg(feature = "acme")]
3523 let mut acme_bind_state: Option<AcmeBindState> = None;
3524
3525 let (bound_listener, bound_desc, unix_socket_cleanup): (
3526 BoundListener,
3527 String,
3528 Option<(std::path::PathBuf, u64, u64)>,
3529 ) = if let Some(socket_path) = config.server.unix_socket.as_deref() {
3530 let _ = socket_path;
3531 #[cfg(unix)]
3532 {
3533 use std::os::unix::fs::PermissionsExt;
3534
3535 let path = std::path::Path::new(socket_path);
3536 if let Err(e) = prepare_unix_socket_path(path) {
3537 tracing::error!(socket = %socket_path, "Failed to prepare unix socket: {e}");
3538 #[cfg(feature = "managed-pg")]
3541 crate::managed_pg::emergency_stop_async().await;
3542 std::process::exit(1);
3543 }
3544 let bind_result = {
3557 static UMASK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3558 let _umask_guard = UMASK_LOCK
3559 .lock()
3560 .unwrap_or_else(std::sync::PoisonError::into_inner);
3561 let prev_umask =
3562 nix::sys::stat::umask(nix::sys::stat::Mode::from_bits_truncate(0o177));
3563 let result = tokio::net::UnixListener::bind(path);
3564 nix::sys::stat::umask(prev_umask);
3565 result
3566 };
3567 let listener = match bind_result {
3568 Ok(listener) => listener,
3569 Err(e) => {
3570 tracing::error!(socket = %socket_path, "Failed to bind unix socket: {e}");
3571 #[cfg(feature = "managed-pg")]
3572 crate::managed_pg::emergency_stop_async().await;
3573 std::process::exit(1);
3574 }
3575 };
3576 if let Err(e) =
3582 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
3583 {
3584 tracing::error!(socket = %socket_path, "Failed to enforce owner-only permissions on unix socket: {e}");
3585 let _ = std::fs::remove_file(path);
3586 #[cfg(feature = "managed-pg")]
3587 crate::managed_pg::emergency_stop_async().await;
3588 std::process::exit(1);
3589 }
3590 let (dev, ino) = {
3593 use std::os::unix::fs::MetadataExt;
3594 std::fs::metadata(path).map_or((0, 0), |m| (m.dev(), m.ino()))
3595 };
3596 (
3597 BoundListener::Unix(listener),
3598 format!("unix:{socket_path}"),
3599 Some((path.to_path_buf(), dev, ino)),
3600 )
3601 }
3602 #[cfg(not(unix))]
3603 {
3604 tracing::error!(
3605 "server.unix_socket is only supported on Unix platforms; \
3606 unset it or use server.host/server.port"
3607 );
3608 std::process::exit(1);
3609 }
3610 } else {
3611 let addr = format!("{}:{}", config.server.host, config.server.port);
3612 let listener = match tokio::net::TcpListener::bind(&addr).await {
3613 Ok(listener) => listener,
3614 Err(e) => {
3615 tracing::error!(addr = %addr, "Failed to bind: {e}");
3616 #[cfg(feature = "managed-pg")]
3619 crate::managed_pg::emergency_stop_async().await;
3620 std::process::exit(1);
3621 }
3622 };
3623 #[cfg(feature = "tls")]
3630 {
3631 if let Some(tls_cfg) = config.server.tls.as_ref() {
3632 #[cfg(feature = "acme")]
3636 if let Some(acme_cfg) = tls_cfg.acme.as_ref() {
3637 let https_port = config.server.port;
3638 match build_acme_tls_listener(
3639 listener,
3640 tls_cfg,
3641 acme_cfg,
3642 https_port,
3643 acme_status.clone(),
3644 server_shutdown.child_token(),
3645 )
3646 .await
3647 {
3648 Ok((tls_listener, bind_state)) => {
3649 acme_bind_state = Some(bind_state);
3650 (
3651 BoundListener::Tls(tls_listener),
3652 format!("https://{addr} (ACME)"),
3653 None,
3654 )
3655 }
3656 Err(e) => {
3657 tracing::error!(error = %e, "Failed to configure [server.tls.acme]");
3658 #[cfg(feature = "managed-pg")]
3659 crate::managed_pg::emergency_stop_async().await;
3660 std::process::exit(1);
3661 }
3662 }
3663 } else {
3664 match build_tls_listener(listener, tls_cfg, server_shutdown.child_token()) {
3665 Ok((tls_listener, reload)) => {
3666 tls_reload_state = Some(reload);
3667 (
3668 BoundListener::Tls(tls_listener),
3669 format!("https://{addr}"),
3670 None,
3671 )
3672 }
3673 Err(e) => {
3674 tracing::error!(error = %e, "Failed to configure [server.tls]");
3675 #[cfg(feature = "managed-pg")]
3676 crate::managed_pg::emergency_stop_async().await;
3677 std::process::exit(1);
3678 }
3679 }
3680 }
3681 #[cfg(not(feature = "acme"))]
3682 match build_tls_listener(listener, tls_cfg, server_shutdown.child_token()) {
3683 Ok((tls_listener, reload)) => {
3684 tls_reload_state = Some(reload);
3685 (
3686 BoundListener::Tls(tls_listener),
3687 format!("https://{addr}"),
3688 None,
3689 )
3690 }
3691 Err(e) => {
3692 tracing::error!(error = %e, "Failed to configure [server.tls]");
3693 #[cfg(feature = "managed-pg")]
3694 crate::managed_pg::emergency_stop_async().await;
3695 std::process::exit(1);
3696 }
3697 }
3698 } else {
3699 (BoundListener::Tcp(listener), addr, None)
3700 }
3701 }
3702 #[cfg(not(feature = "tls"))]
3703 {
3704 (BoundListener::Tcp(listener), addr, None)
3705 }
3706 };
3707
3708 let shutdown_timeout = config.server.shutdown_timeout_secs;
3709 let prestop_grace = config.server.prestop_grace_secs;
3710
3711 if let Err(error) = initialize_job_runtime(
3712 jobs,
3713 &state,
3714 &server_shutdown,
3715 &config.jobs,
3716 role.runs_workers(),
3717 ) {
3718 tracing::error!(error = %error, "job runtime initialization failed");
3719 #[cfg(feature = "managed-pg")]
3722 crate::managed_pg::emergency_stop_async().await;
3723 std::process::exit(1);
3724 }
3725
3726 #[cfg(feature = "db")]
3727 {
3728 #[cfg(feature = "ws")]
3729 crate::repository_commit_hooks::set_global_channels(state.channels().clone());
3730 }
3731
3732 #[cfg(all(feature = "db", not(feature = "sqlite")))]
3742 if role.runs_workers()
3743 && let Some(pool) = state.pool().cloned()
3744 {
3745 #[cfg(feature = "ws")]
3746 {
3747 let channels = state.channels().clone();
3748 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3749 pool,
3750 Some(channels),
3751 server_shutdown.child_token(),
3752 );
3753 }
3754 #[cfg(not(feature = "ws"))]
3755 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3756 pool,
3757 server_shutdown.child_token(),
3758 );
3759 }
3760 #[cfg(all(feature = "db", not(feature = "sqlite")))]
3765 if role.runs_workers()
3766 && let Some(shards) = state.shards()
3767 {
3768 for shard in shards.iter() {
3769 #[cfg(feature = "ws")]
3770 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3771 shard.primary_pool().clone(),
3772 Some(state.channels().clone()),
3773 server_shutdown.child_token(),
3774 );
3775 #[cfg(not(feature = "ws"))]
3776 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3777 shard.primary_pool().clone(),
3778 server_shutdown.child_token(),
3779 );
3780 }
3781 }
3782 #[cfg(all(feature = "db", feature = "sqlite"))]
3788 if role.runs_workers()
3789 && let Some(pool) = state.pool().cloned()
3790 {
3791 #[cfg(feature = "ws")]
3792 {
3793 let channels = state.channels().clone();
3794 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3795 pool,
3796 Some(channels),
3797 server_shutdown.child_token(),
3798 );
3799 }
3800 #[cfg(not(feature = "ws"))]
3801 crate::repository_commit_hooks::start_repository_commit_hook_worker(
3802 pool,
3803 server_shutdown.child_token(),
3804 );
3805 }
3806
3807 #[cfg(feature = "presence")]
3808 {
3809 let presence = state.presence().clone();
3810 let sweep_shutdown = server_shutdown.child_token();
3811 tokio::spawn(async move {
3812 let interval = std::time::Duration::from_secs(15);
3813 loop {
3814 tokio::select! {
3815 () = tokio::time::sleep(interval) => {
3816 presence.sweep_expired();
3817 }
3818 () = sweep_shutdown.cancelled() => break,
3819 }
3820 }
3821 });
3822 }
3823
3824 #[cfg(feature = "tls")]
3832 if let Some(reload) = tls_reload_state.take() {
3833 let reload_shutdown = server_shutdown.child_token();
3834 tokio::spawn(async move {
3835 run_tls_cert_reload(reload, reload_shutdown).await;
3836 });
3837 }
3838
3839 #[cfg(feature = "acme")]
3846 if let Some(bind_state) = acme_bind_state.take() {
3847 let AcmeBindState {
3848 mut renewal_task,
3849 tokens,
3850 http_challenge_port,
3851 https_port,
3852 } = bind_state;
3853
3854 let challenge_listeners =
3862 match crate::acme::challenge::bind_challenge_listeners(http_challenge_port).await {
3863 Ok(listeners) => listeners,
3864 Err(e) => {
3865 tracing::error!(
3866 port = http_challenge_port,
3867 "Failed to bind the ACME HTTP-01 challenge listener: {e}. Port \
3868 {http_challenge_port} typically needs privilege (grant \
3869 CAP_NET_BIND_SERVICE), or set [server.tls.acme] http_challenge_port \
3870 to a port a front-end forwards :80 to"
3871 );
3872 #[cfg(feature = "managed-pg")]
3873 crate::managed_pg::emergency_stop_async().await;
3874 std::process::exit(1);
3875 }
3876 };
3877 let challenge_router = crate::acme::challenge::challenge_router(tokens, https_port);
3878 for challenge_listener in challenge_listeners {
3882 let router = challenge_router.clone();
3883 let challenge_shutdown = server_shutdown.child_token();
3884 tokio::spawn(async move {
3885 if let Err(e) = axum::serve(challenge_listener, router)
3886 .with_graceful_shutdown(async move {
3887 challenge_shutdown.cancelled().await;
3888 })
3889 .await
3890 {
3891 tracing::error!(
3892 error = %e,
3893 "ACME challenge listener stopped with an error"
3894 );
3895 }
3896 });
3897 }
3898
3899 let mut leadership_degraded = false;
3912 let coordinator =
3913 match crate::scheduler::coordinator_from_config(&config.scheduler, &state) {
3914 Ok(c) => c,
3915 Err(e) => {
3916 tracing::warn!(
3917 error = %e,
3918 "ACME renewal: falling back to an in-process coordinator"
3919 );
3920 leadership_degraded = !matches!(
3921 config.scheduler.backend,
3922 crate::config::SchedulerBackend::InProcess
3923 );
3924 std::sync::Arc::new(crate::scheduler::InProcessSchedulerCoordinator::new(
3925 config.scheduler.resolved_replica_id(),
3926 ))
3927 }
3928 };
3929 renewal_task.leadership_degraded = leadership_degraded;
3930
3931 if !matches!(
3945 config.scheduler.backend,
3946 crate::config::SchedulerBackend::InProcess
3947 ) {
3948 tracing::warn!(
3949 scheduler_backend = coordinator.backend(),
3950 "ACME HTTP-01 validation is not fleet-safe with the local on-disk token \
3951 store: behind a load balancer the CA's :80 challenge may reach a replica \
3952 without the token (404), and non-leader replicas cannot adopt issued \
3953 certificates from a non-shared store. Run ACME on a single host, or use a \
3954 shared token store / DNS-01 (#1620)"
3955 );
3956 }
3957
3958 #[cfg(feature = "reporting")]
3959 let reporter = make_acme_reporter(acme_reporters);
3960 #[cfg(not(feature = "reporting"))]
3961 let reporter = make_acme_reporter();
3962 let renewal_shutdown = server_shutdown.child_token();
3963 tokio::spawn(async move {
3964 renewal_task
3965 .run(coordinator, reporter, renewal_shutdown)
3966 .await;
3967 });
3968 }
3969
3970 tracing::info!(bound = %bound_desc, "Listening");
3971
3972 let server_shutdown_wait = server_shutdown.clone();
3973 let after_method = tower::Layer::layer(
3985 &crate::middleware::MethodOverrideLayer::new()
3986 .with_max_scan_bytes(config.security.upload.max_request_size_bytes),
3987 router,
3988 );
3989 let service = tower::Layer::layer(
3990 &crate::security::TrustedProxiesLayer::from_config(&config.security.trusted_proxies),
3991 after_method,
3992 );
3993 let server_task = match bound_listener {
4000 BoundListener::Tcp(listener) => {
4001 let make_service =
4002 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4003 std::net::SocketAddr,
4004 >(service);
4005 tokio::spawn(async move {
4006 axum::serve(listener, make_service)
4007 .with_graceful_shutdown(async move {
4008 server_shutdown_wait.cancelled().await;
4009 })
4010 .await
4011 })
4012 }
4013 #[cfg(unix)]
4014 BoundListener::Unix(listener) => {
4015 let service = tower::Layer::layer(
4020 &axum::middleware::from_fn(stamp_loopback_connect_info),
4021 service,
4022 );
4023 let make_service =
4024 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4025 UdsConnectInfo,
4026 >(service);
4027 tokio::spawn(async move {
4028 axum::serve(listener, make_service)
4029 .with_graceful_shutdown(async move {
4030 server_shutdown_wait.cancelled().await;
4031 })
4032 .await
4033 })
4034 }
4035 #[cfg(feature = "tls")]
4045 BoundListener::Tls(listener) => {
4046 use axum::serve::ListenerExt as _;
4047 let listener = listener.tap_io(|_io| {});
4048 let make_service =
4049 axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4050 std::net::SocketAddr,
4051 >(service);
4052 tokio::spawn(async move {
4053 axum::serve(listener, make_service)
4054 .with_graceful_shutdown(async move {
4055 server_shutdown_wait.cancelled().await;
4056 })
4057 .await
4058 })
4059 }
4060 };
4061
4062 let shutdown_state = state.clone();
4063 let shutdown_signal_token = server_shutdown.clone();
4064 #[cfg(feature = "ws")]
4065 let websocket_shutdown = state.shutdown.clone();
4066 let shutdown_metrics = state.metrics.clone();
4068
4069 let drain_started_at: std::sync::Arc<std::sync::OnceLock<std::time::Instant>> =
4073 std::sync::Arc::new(std::sync::OnceLock::new());
4074 let drain_started_clone = std::sync::Arc::clone(&drain_started_at);
4075
4076 let drain_phase_notify = std::sync::Arc::new(tokio::sync::Notify::new());
4080 let drain_phase_notify_for_watchdog = std::sync::Arc::clone(&drain_phase_notify);
4081 let server_entered_drain = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4084 let server_entered_drain_for_watchdog = std::sync::Arc::clone(&server_entered_drain);
4085
4086 let shutdown_task = tokio::spawn(async move {
4103 shutdown_signal().await;
4105 tracing::info!(
4106 phase = "signal_received",
4107 prestop_grace_secs = prestop_grace,
4108 shutdown_timeout_secs = shutdown_timeout,
4109 "shutdown: graceful shutdown initiated"
4110 );
4111
4112 shutdown_state.begin_shutdown();
4114 tracing::info!(phase = "ready_draining", "shutdown: /ready now 503");
4115
4116 if prestop_grace > 0 {
4118 tokio::time::sleep(std::time::Duration::from_secs(prestop_grace)).await;
4119 }
4120 tracing::info!(phase = "listener_stopping", "shutdown: stopping listener");
4121
4122 #[cfg(feature = "ws")]
4124 websocket_shutdown.cancel();
4125
4126 let _ = drain_started_clone.set(std::time::Instant::now());
4130 shutdown_signal_token.cancel();
4131
4132 if !server_entered_drain_for_watchdog.load(std::sync::atomic::Ordering::Acquire) {
4146 tracing::warn!(
4147 phase = "signal_during_startup",
4148 "shutdown: SIGTERM during startup hooks; waiting for drain phase \
4149 to begin before enforcing the drain deadline"
4150 );
4151 drain_phase_notify_for_watchdog.notified().await;
4155 }
4156 tokio::time::sleep(std::time::Duration::from_secs(shutdown_timeout)).await;
4157 if shutdown_metrics.snapshot().http.requests_active == 0 {
4162 return;
4163 }
4164 let aborted = shutdown_metrics.snapshot().http.requests_active;
4165 shutdown_metrics.record_shutdown_aborted(aborted);
4166 tracing::error!(
4167 phase = "in_flight_drain",
4168 timeout_secs = shutdown_timeout,
4169 autumn_shutdown_aborted_requests_total = aborted,
4170 exit_code = 1,
4171 "shutdown: in_flight_drain phase exceeded deadline; terminating"
4172 );
4173 #[cfg(feature = "managed-pg")]
4177 crate::managed_pg::emergency_stop_async().await;
4178 std::process::exit(1);
4179 });
4180
4181 if let Err(error) = run_startup_hooks(&startup_hooks, state.clone()).await {
4182 tracing::error!(error = %error, "startup hook failed");
4183 server_shutdown.cancel();
4184 server_task.abort();
4185 #[cfg(feature = "managed-pg")]
4187 crate::managed_pg::emergency_stop_async().await;
4188 std::process::exit(1);
4189 }
4190
4191 if !state.probes().is_shutting_down() {
4192 if role.runs_workers() && !tasks.is_empty() {
4196 let res = start_task_scheduler_with_config(
4197 tasks,
4198 &state,
4199 &server_shutdown,
4200 &config.scheduler,
4201 );
4202 if let Err(err) = res {
4203 tracing::error!(error = %err, "scheduled task runtime initialization failed");
4204 server_shutdown.cancel();
4205 server_task.abort();
4206 #[cfg(feature = "managed-pg")]
4208 crate::managed_pg::emergency_stop_async().await;
4209 std::process::exit(1);
4210 }
4211 }
4212 state.probes().mark_startup_complete();
4213 signal_serve_ready(
4214 config
4215 .server
4216 .prestop_grace_secs
4217 .saturating_add(config.server.shutdown_timeout_secs),
4218 );
4219 }
4220
4221 server_entered_drain.store(true, std::sync::atomic::Ordering::Release);
4226 drain_phase_notify.notify_one();
4227
4228 let server_result = server_task.await.unwrap_or_else(|e| {
4231 tracing::error!("Server task join error: {e}");
4232 exit_stop_managed_pg();
4236 std::process::exit(1);
4237 });
4238 shutdown_task.abort();
4240 server_result.unwrap_or_else(|e| {
4241 tracing::error!("Server error: {e}");
4242 exit_stop_managed_pg();
4243 std::process::exit(1);
4244 });
4245
4246 let drain_elapsed = drain_started_at
4251 .get()
4252 .map_or(std::time::Duration::ZERO, std::time::Instant::elapsed);
4253 let hook_budget =
4254 std::time::Duration::from_secs(shutdown_timeout).saturating_sub(drain_elapsed);
4255 run_shutdown_hooks_with_timeout(&shutdown_hooks, hook_budget, hook_budget).await;
4256 #[cfg(feature = "managed-pg")]
4262 crate::managed_pg::emergency_stop_async().await;
4263
4264 #[cfg(unix)]
4271 if let Some((path, dev, ino)) = &unix_socket_cleanup {
4272 use std::os::unix::fs::MetadataExt;
4273 let still_ours =
4274 std::fs::metadata(path).is_ok_and(|m| m.dev() == *dev && m.ino() == *ino);
4275 if still_ours {
4276 let _ = std::fs::remove_file(path);
4277 }
4278 }
4279 #[cfg(not(unix))]
4280 let _ = &unix_socket_cleanup;
4281
4282 tracing::info!(exit_code = 0, "shutdown: all phases completed cleanly");
4283 }
4284
4285 #[allow(clippy::too_many_lines)]
4291 async fn run_build_mode(self) {
4292 let Self {
4293 routes,
4294 api_versions,
4295 route_sources: _,
4296 current_plugin: _,
4297 tasks: _,
4298 one_off_tasks: _,
4299 jobs: _,
4300 listeners,
4301 static_metas,
4302 exception_filters: _,
4303 scoped_groups,
4304 merge_routers: _,
4305 nest_routers: _,
4306 custom_layers,
4307 static_gate_layers: _,
4308 startup_hooks: _,
4309 state_initializers,
4310 shutdown_hooks: _,
4311 extensions: _,
4312 registered_plugins: _,
4313 plugin_config_roots,
4314 #[cfg(feature = "maud")]
4315 error_page_renderer: _,
4316 #[cfg(feature = "db")]
4317 migrations: _,
4318 config_loader_factory,
4319 #[cfg(feature = "db")]
4320 pool_provider_factory,
4321 #[cfg(feature = "db")]
4322 shard_provider_factory,
4323 #[cfg(feature = "db")]
4324 shard_router,
4325 #[cfg(feature = "db")]
4326 directory_shard_router,
4327 telemetry_provider,
4328 session_store,
4329 #[cfg(feature = "ws")]
4330 channels_backend,
4331 #[cfg(feature = "storage")]
4332 blob_store,
4333 cache_backend,
4334 #[cfg(feature = "reporting")]
4335 error_reporters,
4336 alert_channels: _,
4337 #[cfg(feature = "openapi")]
4338 openapi,
4339 #[cfg(feature = "mcp")]
4340 mcp: _,
4341 audit_logger: _,
4342 #[cfg(feature = "i18n")]
4343 i18n_bundle,
4344 #[cfg(feature = "i18n")]
4345 i18n_auto_load,
4346 #[cfg(feature = "embed-assets")]
4347 embedded_static,
4348 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
4349 embedded_locales,
4350 policy_registrations,
4351 #[cfg(feature = "mail")]
4352 mail_delivery_queue_factory,
4353 #[cfg(feature = "mail")]
4354 suppression_store,
4355 #[cfg(feature = "mail")]
4356 mail_suppression_store,
4357 #[cfg(feature = "mail")]
4358 mount_unsubscribe_endpoint,
4359 #[cfg(feature = "mail")]
4360 mail_previews,
4361 #[cfg(feature = "maud")]
4362 story_gallery,
4363 declared_routes: _,
4364 idempotency_enabled,
4365 #[cfg(feature = "mail")]
4366 mail_interceptor,
4367 job_interceptor,
4368 #[cfg(feature = "db")]
4369 db_interceptor,
4370 #[cfg(feature = "ws")]
4371 channels_interceptor,
4372 #[cfg(feature = "oauth2")]
4373 http_interceptor,
4374 seo_sources,
4375 metrics_sources,
4376 health_indicators,
4377 #[cfg(feature = "inbound-mail")]
4378 inbound_mail_router: _,
4379 } = self;
4380
4381 let _ = &api_versions;
4382 let _ = &metrics_sources;
4383 let _ = &health_indicators;
4384 let all_routes = routes;
4385
4386 let (mut config, telemetry_guard) = load_config_and_telemetry(
4388 config_loader_factory,
4389 telemetry_provider,
4390 plugin_config_roots,
4391 )
4392 .await;
4393
4394 #[cfg(feature = "mail")]
4395 if mount_unsubscribe_endpoint {
4396 config.mail.mount_unsubscribe_endpoint = true;
4397 }
4398 if idempotency_enabled {
4399 let env_disabled = std::env::var("AUTUMN_IDEMPOTENCY__ENABLED")
4400 .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "false" | "0" | "no" | "off"));
4401 if !env_disabled && config.idempotency.enabled != Some(false) {
4404 config.idempotency.enabled = Some(true);
4405 }
4406 }
4407
4408 #[cfg(feature = "embed-assets")]
4413 register_embedded_static_dir(embedded_static);
4414
4415 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
4416 let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
4417
4418 #[cfg(feature = "i18n")]
4419 let i18n_bundle =
4420 resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
4421
4422 #[cfg(feature = "openapi")]
4426 let api_docs_snapshot: Vec<crate::openapi::ApiDoc> = {
4427 let mut docs: Vec<crate::openapi::ApiDoc> = all_routes
4428 .iter()
4429 .map(|r| {
4430 let mut doc = r.api_doc.clone();
4431 doc.api_version = r.api_version;
4432 doc.sunset_opt_out = r.sunset_opt_out;
4433 doc
4434 })
4435 .collect();
4436 for group in &scoped_groups {
4437 let prefix_params = crate::router::extract_path_params(&group.prefix);
4441 for route in &group.routes {
4442 let mut doc = route.api_doc.clone();
4443 doc.api_version = route.api_version;
4444 doc.sunset_opt_out = route.sunset_opt_out;
4445 let full = crate::router::join_nested_path(&group.prefix, route.api_doc.path);
4446 doc.path = Box::leak(full.into_boxed_str());
4447 if !prefix_params.is_empty() {
4448 let mut merged: Vec<&'static str> = prefix_params
4449 .iter()
4450 .map(|p| &*Box::leak(p.clone().into_boxed_str()))
4451 .collect();
4452 merged.extend_from_slice(doc.path_params);
4453 doc.path_params = Box::leak(merged.into_boxed_slice());
4454 }
4455 docs.push(doc);
4456 }
4457 }
4458 docs
4459 };
4460
4461 if static_metas.is_empty() {
4462 eprintln!("No static routes registered. Nothing to build.");
4463 eprintln!("Hint: use .static_routes(static_routes![...]) on your AppBuilder.");
4464 std::process::exit(1);
4465 }
4466
4467 fail_fast_on_invalid_session_config(&config, session_store.is_some());
4471 fail_fast_on_invalid_signing_secret(&config);
4472 fail_fast_on_missing_encryption_keys(&config);
4473 fail_fast_on_invalid_trusted_hosts(&config);
4474
4475 #[cfg(feature = "storage")]
4482 let storage_bootstrap = blob_store.map_or_else(
4483 || preflight_storage(&config),
4484 |store| {
4485 Some(StorageBootstrap {
4486 store,
4487 serving: None,
4488 })
4489 },
4490 );
4491
4492 #[cfg(feature = "db")]
4494 let database = setup_database(
4495 &config,
4496 vec![],
4497 pool_provider_factory,
4498 shard_provider_factory,
4499 shard_router,
4500 directory_shard_router,
4501 RepositoryCommitHookQueueMigrationMode::StaticBuild,
4502 )
4503 .await
4504 .unwrap_or_else(|e| {
4505 eprintln!("{e}");
4506 std::process::exit(1);
4507 });
4508 #[cfg(feature = "db")]
4509 let pool = database.topology;
4510 #[cfg(feature = "db")]
4511 let shards = database.shards;
4512 #[cfg(feature = "db")]
4513 let replica_readiness = database.replica_readiness;
4514 #[cfg(feature = "db")]
4515 let replica_migration_check = database.replica_migration_check;
4516
4517 let mut state = build_state(
4518 &config,
4519 #[cfg(feature = "db")]
4520 pool.as_ref(),
4521 #[cfg(feature = "db")]
4522 shards,
4523 #[cfg(feature = "ws")]
4524 channels_backend,
4525 );
4526 if let Some(buf) = telemetry_guard.log_buffer.clone() {
4527 state.insert_extension(buf);
4528 }
4529 if let Some(handle) = telemetry_guard.filter_reload.clone() {
4533 state.log_levels().attach_reload_handle(handle);
4534 }
4535 state.insert_extension(RegisteredApiVersions(api_versions.clone()));
4536 #[cfg(feature = "mail")]
4537 if let Some(interceptor) = mail_interceptor {
4538 state.insert_extension(interceptor);
4539 }
4540 if let Some(interceptor) = job_interceptor {
4541 state.insert_extension(interceptor);
4542 }
4543 #[cfg(feature = "db")]
4544 if let Some(interceptor) = db_interceptor {
4545 state.insert_extension(interceptor);
4546 }
4547 #[cfg(feature = "ws")]
4548 if let Some(interceptor) = channels_interceptor {
4549 state.insert_extension(interceptor.clone());
4550 state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
4551 crate::channels::InterceptedChannelsBackend::new(
4552 state.channels.backend().clone(),
4553 vec![interceptor],
4554 ),
4555 ));
4556 #[cfg(feature = "presence")]
4557 {
4558 state.presence = crate::presence::Presence::new(state.channels.clone());
4559 }
4560 }
4561 #[cfg(feature = "oauth2")]
4562 if let Some(interceptor) = http_interceptor {
4563 state.insert_extension(interceptor);
4564 }
4565 #[cfg(feature = "db")]
4566 configure_replica_migration_check(&state, replica_migration_check);
4567 #[cfg(feature = "db")]
4568 apply_replica_migration_readiness(&state, replica_readiness);
4569 if let Some(cache) = cache_backend {
4570 crate::cache::set_global_cache(cache.clone());
4571 state.shared_cache = Some(cache);
4572 } else {
4573 crate::cache::clear_global_cache();
4574 }
4575 #[cfg(feature = "reporting")]
4576 if !error_reporters.is_empty() {
4577 state.insert_extension(crate::reporting::RegisteredReporters(error_reporters));
4578 }
4579 #[cfg(feature = "mail")]
4586 if let Some(handle) = suppression_store {
4587 state.insert_extension(handle);
4588 }
4589 #[cfg(feature = "mail")]
4590 if let Some(handle) = mail_suppression_store {
4591 state.insert_extension(handle);
4592 }
4593 #[cfg(feature = "mail")]
4594 crate::mail::install_mailer_with_factory(
4595 &state,
4596 &config.mail,
4597 mail_delivery_queue_factory,
4598 false,
4599 )
4600 .unwrap_or_else(|error| {
4601 eprintln!("Failed to configure mailer: {error}");
4602 exit_stop_managed_pg();
4603 std::process::exit(1);
4604 });
4605 #[cfg(feature = "mail")]
4606 state.insert_extension(crate::mail::MailPreviewRegistry::new(mail_previews));
4607 #[cfg(feature = "maud")]
4608 install_story_registry(&state, story_gallery);
4609 state.probes = crate::probe::ProbeState::default();
4611
4612 for register in policy_registrations {
4622 register(state.policy_registry());
4623 }
4624
4625 #[cfg(feature = "i18n")]
4626 let custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
4627
4628 #[cfg(feature = "storage")]
4632 let storage_router = storage_bootstrap.and_then(|b| b.install(&state));
4633 install_webhook_registry(&state, &config);
4634 run_state_initializers(state_initializers, &state);
4635 let sync_listeners: Vec<_> = listeners
4640 .into_iter()
4641 .filter(|listener| listener.mode == crate::events::DispatchMode::Sync)
4642 .collect();
4643 finalize_event_bus(sync_listeners, &mut Vec::new(), &state);
4644
4645 #[cfg_attr(not(feature = "storage"), allow(unused_mut))]
4653 let mut merge_routers: Vec<axum::Router<AppState>> = Vec::new();
4654 #[cfg(feature = "storage")]
4655 if let Some(router) = storage_router {
4656 merge_routers.push(router);
4657 }
4658 let router = crate::router::try_build_router_inner(
4659 all_routes,
4660 &config,
4661 state,
4662 crate::router::RouterContext {
4663 exception_filters: Vec::new(),
4664 scoped_groups,
4665 merge_routers,
4666 nest_routers: Vec::new(),
4667 custom_layers,
4668 static_gate_layers: Vec::new(),
4669 #[cfg(feature = "maud")]
4670 error_page_renderer: None,
4671 session_store,
4672 #[cfg(feature = "openapi")]
4673 openapi: None,
4674 #[cfg(feature = "mcp")]
4675 mcp: None,
4676 },
4677 )
4678 .unwrap_or_else(|error| {
4679 eprintln!("Failed to build router: {error}");
4680 exit_stop_managed_pg();
4681 std::process::exit(1);
4682 });
4683
4684 let env = crate::config::OsEnv;
4685 let dist_dir = project_dir("dist", &env);
4686
4687 eprintln!("Building {} static route(s)...", static_metas.len());
4688
4689 match crate::static_gen::render_static_routes(router, &static_metas, &dist_dir).await {
4690 Ok(()) => {
4691 eprintln!(
4692 "\n \u{2713} Static build complete \u{2192} {}",
4693 dist_dir.display()
4694 );
4695 }
4696 Err(e) => {
4697 eprintln!("\n \u{2717} Static build failed: {e}");
4698 exit_stop_managed_pg();
4699 std::process::exit(1);
4700 }
4701 }
4702
4703 #[cfg(feature = "openapi")]
4706 if let Some(mut openapi_config) = openapi {
4707 openapi_config.api_versions = api_versions;
4708 let openapi_config =
4709 openapi_config.session_cookie_name(config.session.cookie_name.clone());
4710 let docs: Vec<&crate::openapi::ApiDoc> = api_docs_snapshot.iter().collect();
4711 let spec = crate::openapi::generate_spec(&openapi_config, &docs);
4712 match crate::openapi::write_openapi_spec_to_dist(&spec, &dist_dir) {
4713 Ok(()) => {
4714 eprintln!(
4715 " \u{2713} OpenAPI spec written \u{2192} {}/openapi.json",
4716 dist_dir.display()
4717 );
4718 }
4719 Err(e) => {
4720 eprintln!(" \u{26A0} Failed to write OpenAPI spec: {e}");
4721 }
4722 }
4723 }
4724
4725 if !seo_sources.is_empty() || crate::seo::has_seo_config(&config.seo) {
4729 let seo_cfg = &config.seo;
4730 let raw_profile = config.profile.as_deref().unwrap_or("dev");
4731 let profile = crate::seo::effective_seo_profile(raw_profile, seo_cfg.robots.allow_all);
4732 let static_paths: Vec<&str> = static_metas.iter().map(|m| m.path).collect();
4733 let (robots_body, sitemap_body) = crate::seo::assemble_seo_bodies(
4734 profile,
4735 seo_cfg.base_url.as_deref(),
4736 seo_cfg.robots.sitemap_url.as_deref(),
4737 &seo_cfg.robots.additional_rules,
4738 &seo_sources,
4739 &static_paths,
4740 )
4741 .await;
4742 let robots_path = dist_dir.join("robots.txt");
4745 let sitemap_path = dist_dir.join("sitemap.xml");
4746 if robots_path.exists() {
4747 eprintln!(
4748 " \u{2713} SEO: robots.txt already present (custom static route), skipping"
4749 );
4750 } else {
4751 match tokio::fs::write(&robots_path, robots_body).await {
4752 Ok(()) => eprintln!(
4753 " \u{2713} SEO: robots.txt written \u{2192} {}",
4754 robots_path.display()
4755 ),
4756 Err(e) => eprintln!(" \u{26A0} Failed to write robots.txt: {e}"),
4757 }
4758 }
4759 if sitemap_path.exists() {
4760 eprintln!(
4761 " \u{2713} SEO: sitemap.xml already present (custom static route), skipping"
4762 );
4763 } else {
4764 match tokio::fs::write(&sitemap_path, sitemap_body).await {
4765 Ok(()) => eprintln!(
4766 " \u{2713} SEO: sitemap.xml written \u{2192} {}",
4767 sitemap_path.display()
4768 ),
4769 Err(e) => eprintln!(" \u{26A0} Failed to write sitemap.xml: {e}"),
4770 }
4771 }
4772 }
4773
4774 #[cfg(feature = "managed-pg")]
4778 crate::managed_pg::emergency_stop_async().await;
4779 }
4780
4781 #[allow(clippy::too_many_lines)]
4787 async fn run_dump_routes_mode(self) {
4788 let Self {
4789 routes,
4790 api_versions,
4791 route_sources,
4792 scoped_groups,
4793 merge_routers,
4794 nest_routers,
4795 declared_routes,
4796 config_loader_factory,
4797 telemetry_provider,
4798 #[cfg(feature = "openapi")]
4799 openapi,
4800 plugin_config_roots,
4801 ..
4802 } = self;
4803
4804 let registered_versions: std::collections::HashSet<&str> =
4806 api_versions.iter().map(|av| av.version.as_str()).collect();
4807
4808 for route in &routes {
4809 if let Some(ver) = route
4810 .api_version
4811 .filter(|ver| !registered_versions.contains(*ver))
4812 {
4813 eprintln!(
4814 "Failed to build router: route '{}' uses unregistered API version '{}'",
4815 route.name, ver
4816 );
4817 std::process::exit(1);
4818 }
4819 }
4820
4821 for group in &scoped_groups {
4822 for route in &group.routes {
4823 if let Some(ver) = route
4824 .api_version
4825 .filter(|ver| !registered_versions.contains(*ver))
4826 {
4827 eprintln!(
4828 "Failed to build router: route '{}' uses unregistered API version '{}'",
4829 route.name, ver
4830 );
4831 std::process::exit(1);
4832 }
4833 }
4834 }
4835
4836 let hidden = omitted_router_count(
4846 merge_routers.len(),
4847 nest_routers.iter().map(|(prefix, _)| prefix.as_str()),
4848 &declared_routes,
4849 );
4850 if hidden > 0 {
4851 eprintln!(
4852 "[autumn routes] warning: {hidden} raw router(s) added via \
4853 .merge()/.nest() are not enumerable and are omitted from this listing"
4854 );
4855 eprintln!(
4858 "{marker}{hidden}",
4859 marker = crate::route_listing::OMITTED_ROUTES_MARKER
4860 );
4861 }
4862
4863 let (config, _telemetry_guard) = load_config_and_telemetry(
4864 config_loader_factory,
4865 telemetry_provider,
4866 plugin_config_roots,
4867 )
4868 .await;
4869
4870 if is_dump_security_mode() {
4875 let security = crate::route_listing::SecurityDump::from_config(&config);
4876 match serde_json::to_string(&security) {
4877 Ok(json) => eprintln!(
4878 "{marker}{json}",
4879 marker = crate::route_listing::SECURITY_CONFIG_MARKER
4880 ),
4881 Err(e) => eprintln!("Failed to serialize security config: {e}"),
4882 }
4883 }
4884
4885 let mut infos = match crate::route_listing::collect_route_infos(
4886 &routes,
4887 &route_sources,
4888 &scoped_groups,
4889 &api_versions,
4890 ) {
4891 Ok(infos) => infos,
4892 Err(e) => {
4893 eprintln!("Failed to build router: {e}");
4894 std::process::exit(1);
4895 }
4896 };
4897 infos.extend(declared_routes);
4898 crate::route_listing::append_framework_routes(&mut infos, &config);
4899 #[cfg(feature = "openapi")]
4900 if let Some(ref oa) = openapi {
4901 crate::route_listing::append_openapi_routes(&mut infos, oa);
4902 }
4903 crate::route_listing::append_dev_reload_routes(&mut infos);
4904 crate::route_listing::sort_route_infos(&mut infos);
4905
4906 let json = serde_json::to_string_pretty(&infos).unwrap_or_else(|e| {
4907 eprintln!("Failed to serialize route listing: {e}");
4908 std::process::exit(1);
4909 });
4910 println!("{json}");
4911 std::process::exit(0);
4912 }
4913
4914 async fn run_dump_jobs_mode(self) {
4923 let Self {
4924 jobs,
4925 listeners,
4926 config_loader_factory,
4927 telemetry_provider,
4928 plugin_config_roots,
4929 ..
4930 } = self;
4931
4932 let (config, _telemetry_guard) = load_config_and_telemetry(
4933 config_loader_factory,
4934 telemetry_provider,
4935 plugin_config_roots,
4936 )
4937 .await;
4938
4939 let manifest = dump_jobs_manifest(&config.jobs.queues, jobs, listeners);
4943 print!("{manifest}");
4944 std::process::exit(0);
4945 }
4946
4947 fn run_list_one_off_tasks_mode(self) {
4951 let Self { one_off_tasks, .. } = self;
4952
4953 if let Err(error) = crate::task::validate_unique_one_off_task_names(&one_off_tasks) {
4954 eprintln!("Invalid task registration: {error}");
4955 std::process::exit(1);
4956 }
4957
4958 let listing = crate::task::list_one_off_tasks(&one_off_tasks);
4959 let json = serde_json::to_string_pretty(&listing).unwrap_or_else(|error| {
4960 eprintln!("Failed to serialize task listing: {error}");
4961 std::process::exit(1);
4962 });
4963 println!("{json}");
4964 std::process::exit(0);
4965 }
4966
4967 #[cfg(feature = "db")]
4986 async fn run_migrate_only_mode(self) {
4987 let Self {
4988 migrations,
4989 config_loader_factory,
4990 telemetry_provider,
4991 plugin_config_roots,
4992 ..
4993 } = self;
4994
4995 let (config, _telemetry_guard) = load_config_and_telemetry(
4999 config_loader_factory,
5000 telemetry_provider,
5001 plugin_config_roots,
5002 )
5003 .await;
5004
5005 let migrations = migrations_with_repository_framework_migrations(
5008 migrations,
5009 crate::repository_commit_hooks::has_repository_commit_hook_descriptors(),
5010 crate::version_history::has_versioned_repository_descriptors(),
5011 RepositoryCommitHookQueueMigrationMode::Runtime,
5012 );
5013
5014 let control_url = config.database.effective_primary_url().map(str::to_owned);
5016 let shard_targets: Vec<(String, String)> = config
5017 .database
5018 .shards
5019 .iter()
5020 .map(|shard| (format!("shard:{}", shard.name), shard.primary_url.clone()))
5021 .collect();
5022
5023 if migrations.is_empty() || (control_url.is_none() && shard_targets.is_empty()) {
5024 eprintln!(
5025 "autumn migrate: no database configured or no migrations registered — nothing to apply"
5026 );
5027 std::process::exit(0);
5028 }
5029
5030 #[cfg(feature = "sqlite")]
5039 {
5040 let sqlite_guard_shard_urls: Vec<&str> =
5041 shard_targets.iter().map(|(_, url)| url.as_str()).collect();
5042 if let Err(e) = sqlite_sharding_unsupported_guard(
5043 control_url.as_deref(),
5044 !shard_targets.is_empty(),
5045 &sqlite_guard_shard_urls,
5046 ) {
5047 eprintln!("autumn migrate: {e}");
5048 #[cfg(feature = "managed-pg")]
5051 crate::managed_pg::emergency_stop();
5052 std::process::exit(1);
5053 }
5054 }
5055
5056 let applied_total = tokio::task::spawn_blocking(move || {
5059 let mut total = 0_usize;
5060 if let Some(url) = &control_url {
5061 #[cfg(feature = "sqlite")]
5067 let is_sqlite_control = crate::config::DatabaseBackend::detect(url)
5068 == Some(crate::config::DatabaseBackend::Sqlite);
5069 #[cfg(not(feature = "sqlite"))]
5070 let is_sqlite_control = false;
5071 if is_sqlite_control {
5072 #[cfg(feature = "sqlite")]
5073 for mig in &migrations {
5074 total += apply_pending_sqlite_or_exit(url, mig, "control");
5075 }
5076 } else {
5077 for mig in &migrations {
5078 total += apply_pending_or_exit(url, mig, "control");
5079 }
5080 }
5081 }
5082 for (label, url) in &shard_targets {
5087 for mig in migrations
5088 .iter()
5089 .filter(|mig| !migration_set_is_control_framework(mig))
5090 {
5091 total += apply_pending_or_exit(url, mig, label);
5092 }
5093 }
5094 total
5095 })
5096 .await
5097 .unwrap_or_else(|error| {
5098 eprintln!("autumn migrate: migration task panicked: {error}");
5099 std::process::exit(1);
5100 });
5101
5102 eprintln!(
5103 "autumn migrate: applied {applied_total} pending migration(s); database is up to date"
5104 );
5105 std::process::exit(0);
5106 }
5107
5108 #[cfg(not(feature = "db"))]
5112 #[allow(clippy::unused_async)]
5113 async fn run_migrate_only_mode(self) {
5114 eprintln!("autumn migrate: this build has no database support — nothing to migrate");
5115 std::process::exit(0);
5116 }
5117
5118 #[allow(clippy::too_many_lines)]
5122 #[allow(clippy::cognitive_complexity)]
5123 async fn run_one_off_task_mode(self, requested_name: String) {
5124 let Self {
5125 one_off_tasks,
5126 mut jobs,
5127 listeners,
5128 #[cfg(feature = "i18n")]
5129 custom_layers,
5130 #[cfg(not(feature = "i18n"))]
5131 custom_layers: _,
5132 startup_hooks,
5133 state_initializers,
5134 shutdown_hooks,
5135 config_loader_factory,
5136 #[cfg(feature = "db")]
5137 migrations,
5138 #[cfg(feature = "db")]
5139 pool_provider_factory,
5140 #[cfg(feature = "db")]
5141 shard_provider_factory,
5142 #[cfg(feature = "db")]
5143 shard_router,
5144 #[cfg(feature = "db")]
5145 directory_shard_router,
5146 telemetry_provider,
5147 session_store,
5148 #[cfg(feature = "ws")]
5149 channels_backend,
5150 #[cfg(feature = "storage")]
5151 blob_store,
5152 audit_logger,
5153 #[cfg(feature = "i18n")]
5154 i18n_bundle,
5155 #[cfg(feature = "i18n")]
5156 i18n_auto_load,
5157 #[cfg(feature = "embed-assets")]
5158 embedded_static,
5159 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
5160 embedded_locales,
5161 policy_registrations,
5162 cache_backend,
5163 #[cfg(feature = "mail")]
5164 mail_delivery_queue_factory,
5165 #[cfg(feature = "mail")]
5166 suppression_store,
5167 #[cfg(feature = "mail")]
5168 mail_suppression_store,
5169 #[cfg(feature = "mail")]
5170 mount_unsubscribe_endpoint: _,
5171 #[cfg(feature = "mail")]
5172 mail_interceptor,
5173 job_interceptor,
5174 #[cfg(feature = "db")]
5175 db_interceptor,
5176 #[cfg(feature = "ws")]
5177 channels_interceptor,
5178 #[cfg(feature = "oauth2")]
5179 http_interceptor,
5180 plugin_config_roots,
5181 ..
5182 } = self;
5183
5184 if let Err(error) = crate::task::validate_unique_one_off_task_names(&one_off_tasks) {
5185 eprintln!("Invalid task registration: {error}");
5186 std::process::exit(1);
5187 }
5188
5189 let Some((task_name, task_handler)) = one_off_tasks
5190 .iter()
5191 .find(|task| task.name == requested_name)
5192 .map(|task| (task.name.clone(), task.handler))
5193 else {
5194 eprintln!("No one-off task named '{requested_name}' is registered.");
5195 print_available_one_off_tasks(&one_off_tasks);
5196 std::process::exit(1);
5197 };
5198
5199 let args = one_off_task_args_from_env().unwrap_or_else(|error| {
5200 eprintln!("Invalid task args: {error}");
5201 std::process::exit(1);
5202 });
5203
5204 let (config, telemetry_guard) = load_config_and_telemetry(
5205 config_loader_factory,
5206 telemetry_provider,
5207 plugin_config_roots,
5208 )
5209 .await;
5210
5211 #[cfg(feature = "embed-assets")]
5216 register_embedded_static_dir(embedded_static);
5217
5218 #[cfg(all(feature = "embed-assets", feature = "i18n"))]
5219 let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
5220
5221 #[cfg(feature = "i18n")]
5222 let i18n_bundle =
5223 resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
5224
5225 fail_fast_on_invalid_session_config(&config, session_store.is_some());
5226 fail_fast_on_invalid_signing_secret(&config);
5227 fail_fast_on_missing_encryption_keys(&config);
5228 fail_fast_on_invalid_trusted_hosts(&config);
5229
5230 #[cfg(feature = "storage")]
5231 let storage_bootstrap = blob_store.map_or_else(
5232 || preflight_storage(&config),
5233 |store| {
5234 Some(StorageBootstrap {
5235 store,
5236 serving: None,
5237 })
5238 },
5239 );
5240
5241 #[cfg(feature = "db")]
5242 let database = setup_database(
5243 &config,
5244 migrations,
5245 pool_provider_factory,
5246 shard_provider_factory,
5247 shard_router,
5248 directory_shard_router,
5249 RepositoryCommitHookQueueMigrationMode::Runtime,
5250 )
5251 .await
5252 .unwrap_or_else(|error| {
5253 eprintln!("{error}");
5254 std::process::exit(1);
5255 });
5256 #[cfg(feature = "db")]
5257 let pool = database.topology;
5258 #[cfg(feature = "db")]
5259 let shards = database.shards;
5260 #[cfg(feature = "db")]
5261 let replica_readiness = database.replica_readiness;
5262 #[cfg(feature = "db")]
5263 let replica_migration_check = database.replica_migration_check;
5264
5265 let mut state = build_state(
5266 &config,
5267 #[cfg(feature = "db")]
5268 pool.as_ref(),
5269 #[cfg(feature = "db")]
5270 shards,
5271 #[cfg(feature = "ws")]
5272 channels_backend,
5273 );
5274 if let Some(buf) = telemetry_guard.log_buffer.clone() {
5275 state.insert_extension(buf);
5276 }
5277 if let Some(handle) = telemetry_guard.filter_reload.clone() {
5281 state.log_levels().attach_reload_handle(handle);
5282 }
5283 #[cfg(feature = "mail")]
5284 if let Some(interceptor) = mail_interceptor {
5285 state.insert_extension(interceptor);
5286 }
5287 if let Some(interceptor) = job_interceptor {
5288 state.insert_extension(interceptor);
5289 }
5290 #[cfg(feature = "db")]
5291 if let Some(interceptor) = db_interceptor {
5292 state.insert_extension(interceptor);
5293 }
5294 #[cfg(feature = "ws")]
5295 if let Some(interceptor) = channels_interceptor {
5296 state.insert_extension(interceptor.clone());
5297 state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
5298 crate::channels::InterceptedChannelsBackend::new(
5299 state.channels.backend().clone(),
5300 vec![interceptor],
5301 ),
5302 ));
5303 #[cfg(feature = "presence")]
5304 {
5305 state.presence = crate::presence::Presence::new(state.channels.clone());
5306 }
5307 }
5308 #[cfg(feature = "oauth2")]
5309 if let Some(interceptor) = http_interceptor {
5310 state.insert_extension(interceptor);
5311 }
5312 #[cfg(feature = "db")]
5313 configure_replica_migration_check(&state, replica_migration_check);
5314 #[cfg(feature = "db")]
5315 apply_replica_migration_readiness(&state, replica_readiness);
5316 if let Some(cache) = cache_backend {
5317 crate::cache::set_global_cache(cache.clone());
5318 state.shared_cache = Some(cache);
5319 } else {
5320 crate::cache::clear_global_cache();
5321 }
5322
5323 for register in policy_registrations {
5324 register(state.policy_registry());
5325 }
5326
5327 #[cfg(feature = "mail")]
5328 if let Some(handle) = suppression_store {
5329 state.insert_extension(handle);
5330 }
5331 #[cfg(feature = "mail")]
5332 if let Some(handle) = mail_suppression_store {
5333 state.insert_extension(handle);
5334 }
5335 #[cfg(feature = "mail")]
5336 crate::mail::install_mailer_with_factory(
5337 &state,
5338 &config.mail,
5339 mail_delivery_queue_factory,
5340 true,
5341 )
5342 .unwrap_or_else(|error| {
5343 eprintln!("Failed to configure mailer: {error}");
5344 exit_stop_managed_pg();
5345 std::process::exit(1);
5346 });
5347
5348 if let Some(logger) = audit_logger {
5349 state.insert_extension::<crate::audit::AuditLogger>((*logger).clone());
5350 }
5351
5352 #[cfg(feature = "i18n")]
5353 let _custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
5354
5355 #[cfg(feature = "storage")]
5356 let _storage_router = storage_bootstrap.and_then(|bootstrap| bootstrap.install(&state));
5357 run_state_initializers(state_initializers, &state);
5358 finalize_event_bus(listeners, &mut jobs, &state);
5359
5360 let task_shutdown = tokio_util::sync::CancellationToken::new();
5361 if let Err(error) = initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)
5362 {
5363 eprintln!("job runtime initialization failed: {error}");
5364 #[cfg(feature = "managed-pg")]
5365 crate::managed_pg::emergency_stop_async().await;
5366 std::process::exit(1);
5367 }
5368
5369 #[cfg(feature = "db")]
5370 {
5371 #[cfg(feature = "ws")]
5372 crate::repository_commit_hooks::set_global_channels(state.channels().clone());
5373 }
5374
5375 #[cfg(all(feature = "db", not(feature = "sqlite")))]
5378 if let Some(pool) = state.pool().cloned() {
5379 #[cfg(feature = "ws")]
5380 {
5381 let channels = state.channels().clone();
5382 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5383 pool,
5384 Some(channels),
5385 task_shutdown.child_token(),
5386 );
5387 }
5388 #[cfg(not(feature = "ws"))]
5389 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5390 pool,
5391 task_shutdown.child_token(),
5392 );
5393 }
5394 #[cfg(all(feature = "db", not(feature = "sqlite")))]
5397 if let Some(shards) = state.shards() {
5398 for shard in shards.iter() {
5399 #[cfg(feature = "ws")]
5400 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5401 shard.primary_pool().clone(),
5402 Some(state.channels().clone()),
5403 task_shutdown.child_token(),
5404 );
5405 #[cfg(not(feature = "ws"))]
5406 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5407 shard.primary_pool().clone(),
5408 task_shutdown.child_token(),
5409 );
5410 }
5411 }
5412 #[cfg(all(feature = "db", feature = "sqlite"))]
5415 if let Some(pool) = state.pool().cloned() {
5416 #[cfg(feature = "ws")]
5417 {
5418 let channels = state.channels().clone();
5419 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5420 pool,
5421 Some(channels),
5422 task_shutdown.child_token(),
5423 );
5424 }
5425 #[cfg(not(feature = "ws"))]
5426 crate::repository_commit_hooks::start_repository_commit_hook_worker(
5427 pool,
5428 task_shutdown.child_token(),
5429 );
5430 }
5431
5432 if let Err(error) = run_startup_hooks(&startup_hooks, state.clone()).await {
5433 eprintln!("startup hook failed: {error}");
5434 task_shutdown.cancel();
5435 #[cfg(feature = "managed-pg")]
5436 crate::managed_pg::emergency_stop_async().await;
5437 std::process::exit(1);
5438 }
5439 state.probes().mark_startup_complete();
5440
5441 tracing::info!(task = %task_name, "Running one-off task");
5442 let span = tracing::info_span!("one_off_task", task = %task_name);
5443 #[cfg(feature = "oauth2")]
5444 let result = {
5445 use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
5446 let interceptors: Vec<std::sync::Arc<dyn HttpInterceptor>> = state
5447 .extension::<std::sync::Arc<dyn HttpInterceptor>>()
5448 .map(|interceptor_arc| vec![(*interceptor_arc).clone()])
5449 .unwrap_or_default();
5450 ACTIVE_HTTP_INTERCEPTORS
5451 .scope(
5452 interceptors,
5453 (task_handler)(state.clone(), args).instrument(span),
5454 )
5455 .await
5456 };
5457 #[cfg(not(feature = "oauth2"))]
5458 let result = (task_handler)(state.clone(), args).instrument(span).await;
5459
5460 task_shutdown.cancel();
5461 run_shutdown_hooks(&shutdown_hooks).await;
5462 #[cfg(feature = "managed-pg")]
5467 crate::managed_pg::emergency_stop_async().await;
5468
5469 match result {
5470 Ok(()) => {
5471 tracing::info!(task = %task_name, "One-off task completed");
5472 }
5473 Err(error) => {
5474 tracing::error!(task = %task_name, error = %error, "One-off task failed");
5475 eprintln!("Task '{task_name}' failed: {error}");
5476 for cause in error.source_chain() {
5477 eprintln!("Caused by: {cause}");
5478 }
5479 std::process::exit(1);
5480 }
5481 }
5482 }
5483}
5484
5485pub(crate) fn is_static_build_mode() -> bool {
5486 std::env::var("AUTUMN_BUILD_STATIC").as_deref() == Ok("1")
5487}
5488
5489#[allow(clippy::missing_const_for_fn)]
5500fn exit_stop_managed_pg() {
5501 #[cfg(feature = "managed-pg")]
5502 {
5503 let _ = std::thread::spawn(crate::managed_pg::emergency_stop).join();
5504 }
5505}
5506
5507pub(crate) fn is_dump_routes_mode() -> bool {
5508 std::env::var("AUTUMN_DUMP_ROUTES").as_deref() == Ok("1")
5509}
5510
5511pub(crate) fn is_dump_security_mode() -> bool {
5518 std::env::var("AUTUMN_DUMP_SECURITY").as_deref() == Ok("1")
5519}
5520
5521pub(crate) fn is_dump_jobs_mode() -> bool {
5522 std::env::var("AUTUMN_DUMP_JOBS").as_deref() == Ok("1")
5523}
5524
5525pub(crate) fn is_list_one_off_tasks_mode() -> bool {
5526 std::env::var("AUTUMN_LIST_TASKS").as_deref() == Ok("1")
5527}
5528
5529pub(crate) fn is_migrate_only_mode() -> bool {
5534 std::env::var("AUTUMN_MIGRATE").as_deref() == Ok("1")
5535}
5536
5537fn one_off_task_name_from_env() -> Option<String> {
5538 std::env::var("AUTUMN_RUN_TASK")
5539 .ok()
5540 .map(|value| value.trim().to_owned())
5541 .filter(|value| !value.is_empty())
5542}
5543
5544fn one_off_task_args_from_env() -> Result<Vec<String>, String> {
5545 match std::env::var("AUTUMN_TASK_ARGS_JSON") {
5546 Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw)
5547 .map_err(|error| format!("AUTUMN_TASK_ARGS_JSON must be a JSON string array: {error}")),
5548 _ => Ok(Vec::new()),
5549 }
5550}
5551
5552fn print_available_one_off_tasks(tasks: &[crate::task::OneOffTaskInfo]) {
5553 let listing = crate::task::list_one_off_tasks(tasks);
5554 if listing.is_empty() {
5555 eprintln!("No one-off tasks are registered. Add .one_off_tasks(one_off_tasks![...]).");
5556 return;
5557 }
5558
5559 eprintln!("Available tasks:");
5560 for task in listing {
5561 if task.description.is_empty() {
5562 eprintln!(" {}", task.name);
5563 } else {
5564 eprintln!(" {:<24} {}", task.name, task.description);
5565 }
5566 }
5567}
5568
5569#[allow(clippy::cast_possible_truncation)]
5576#[allow(clippy::cognitive_complexity)]
5577#[allow(dead_code)]
5578fn start_task_scheduler(
5579 tasks: Vec<crate::task::TaskInfo>,
5580 state: &AppState,
5581 shutdown: &tokio_util::sync::CancellationToken,
5582) {
5583 if let Err(error) = start_task_scheduler_with_config(
5584 tasks,
5585 state,
5586 shutdown,
5587 &crate::config::SchedulerConfig::default(),
5588 ) {
5589 tracing::error!(error = %error, "scheduled task runtime initialization failed");
5590 }
5591}
5592
5593#[allow(clippy::cast_possible_truncation)]
5594#[allow(clippy::cognitive_complexity)]
5595fn start_task_scheduler_with_config(
5596 tasks: Vec<crate::task::TaskInfo>,
5597 state: &AppState,
5598 shutdown: &tokio_util::sync::CancellationToken,
5599 scheduler_config: &crate::config::SchedulerConfig,
5600) -> crate::AutumnResult<()> {
5601 tracing::info!(count = tasks.len(), "Starting scheduled tasks");
5602 let coordinator = crate::scheduler::coordinator_from_config(scheduler_config, state)?;
5603 let lease_ttl = std::time::Duration::from_secs(scheduler_config.lease_ttl_secs);
5604 for task_info in &tasks {
5605 let schedule_desc = task_info.schedule.to_string();
5606 tracing::info!(
5607 name = %task_info.name,
5608 schedule = %schedule_desc,
5609 coordination = %task_info.coordination,
5610 scheduler_backend = coordinator.backend(),
5611 replica_id = coordinator.replica_id(),
5612 lease_ttl_secs = scheduler_config.lease_ttl_secs,
5613 "Registered task"
5614 );
5615 }
5616
5617 let mut cron_tasks: Vec<CronTaskSpec> = Vec::new();
5618
5619 for task_info in tasks {
5620 let state = state.clone();
5621 let name = task_info.name.clone();
5622 let handler = task_info.handler;
5623 let coordination = task_info.coordination;
5624 let schedule_desc = task_info.schedule.to_string();
5625 state.task_registry.register_scheduled(
5626 &name,
5627 &schedule_desc,
5628 coordination,
5629 coordinator.backend(),
5630 coordinator.replica_id(),
5631 );
5632
5633 match task_info.schedule {
5634 crate::task::Schedule::FixedDelay(delay) => {
5635 let coordinator = Arc::clone(&coordinator);
5636 let shutdown = shutdown.child_token();
5637 tokio::spawn(async move {
5638 loop {
5639 state
5640 .task_registry
5641 .record_next_run_at(&name, &format_next_task_run_after(delay));
5642 tokio::select! {
5643 () = shutdown.cancelled() => break,
5644 () = tokio::time::sleep(delay) => {
5645 execute_fixed_delay_task(
5646 name.clone(),
5647 state.clone(),
5648 handler,
5649 delay,
5650 coordination,
5651 Arc::clone(&coordinator),
5652 lease_ttl,
5653 )
5654 .await;
5655 }
5656 }
5657 }
5658 });
5659 }
5660 crate::task::Schedule::Cron {
5661 expression,
5662 timezone,
5663 } => {
5664 cron_tasks.push(CronTaskSpec {
5665 name,
5666 expression,
5667 timezone,
5668 coordination,
5669 handler,
5670 });
5671 }
5672 }
5673 }
5674
5675 run_cron_scheduler(cron_tasks, state, shutdown, &coordinator, lease_ttl);
5676
5677 Ok(())
5678}
5679
5680#[allow(unused_variables, clippy::needless_pass_by_value)]
5681fn send_ws_sys_task_msg(
5682 state: &AppState,
5683 event: &str,
5684 name: &str,
5685 extra: Vec<(&str, serde_json::Value)>,
5686) {
5687 #[cfg(feature = "ws")]
5688 {
5689 let mut msg = serde_json::json!({
5693 "event": event,
5694 "task": name,
5695 "timestamp": chrono::Utc::now().to_rfc3339(),
5696 });
5697 if let Some(map) = msg.as_object_mut() {
5698 for (k, v) in extra {
5699 map.insert(k.to_string(), v);
5700 }
5701 }
5702 let _ = state.channels().sender("sys:tasks").send(msg.to_string());
5703 }
5704}
5705
5706async fn execute_task_result(
5707 state: &AppState,
5708 handler: crate::task::TaskHandler,
5709 start: std::time::Instant,
5710 name: &str,
5711 schedule: &'static str,
5712) -> Result<u64, (u64, String)> {
5713 let task_span = tracing::info_span!(
5717 parent: None,
5718 "scheduled_task",
5719 otel.kind = "internal",
5720 task = %name,
5721 schedule = schedule,
5722 );
5723 let future = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5724 (handler)(state.clone()).instrument(task_span)
5725 })) {
5726 Ok(future) => future,
5727 Err(panic) => {
5728 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5729 return Err((duration_ms, format_scheduled_task_panic(panic.as_ref())));
5730 }
5731 };
5732 let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
5733 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5734
5735 match result {
5736 Ok(Ok(())) => Ok(duration_ms),
5737 Ok(Err(e)) => Err((duration_ms, e.to_string())),
5738 Err(panic) => Err((duration_ms, format_scheduled_task_panic(panic.as_ref()))),
5739 }
5740}
5741
5742fn format_scheduled_task_panic(panic: &(dyn Any + Send)) -> String {
5743 let detail = panic
5744 .downcast_ref::<String>()
5745 .map(String::as_str)
5746 .or_else(|| panic.downcast_ref::<&'static str>().copied())
5747 .unwrap_or("non-string panic payload");
5748 format!("scheduled task handler panicked: {detail}")
5749}
5750
5751async fn execute_task_result_with_optional_lease_ttl(
5752 state: &AppState,
5753 handler: crate::task::TaskHandler,
5754 start: std::time::Instant,
5755 name: &str,
5756 schedule: &'static str,
5757 lease_ttl: Option<std::time::Duration>,
5758) -> Result<u64, (u64, String)> {
5759 let Some(lease_ttl) = lease_ttl else {
5760 return execute_task_result(state, handler, start, name, schedule).await;
5761 };
5762
5763 tokio::time::timeout(
5764 lease_ttl,
5765 execute_task_result(state, handler, start, name, schedule),
5766 )
5767 .await
5768 .unwrap_or_else(|_| {
5769 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5770 Err((
5771 duration_ms,
5772 format!(
5773 "scheduled task exceeded lease TTL of {}s",
5774 lease_ttl.as_secs()
5775 ),
5776 ))
5777 })
5778}
5779
5780#[allow(clippy::cognitive_complexity)]
5782async fn execute_fixed_delay_task(
5783 name: String,
5784 state: AppState,
5785 handler: crate::task::TaskHandler,
5786 delay: std::time::Duration,
5787 coordination: crate::task::TaskCoordination,
5788 coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5789 lease_ttl: std::time::Duration,
5790) {
5791 let tick_key = crate::scheduler::fixed_delay_tick_key(
5792 &name,
5793 delay,
5794 crate::time::clock_unix_duration(state.clock()),
5795 );
5796 let lease = match coordinator
5797 .try_acquire(&name, &tick_key, coordination)
5798 .await
5799 {
5800 Ok(Some(lease)) => lease,
5801 Ok(None) => {
5802 tracing::debug!(task = %name, tick = %tick_key, "Scheduled task tick already claimed");
5803 return;
5804 }
5805 Err(error) => {
5806 tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to acquire scheduled task lease");
5807 return;
5808 }
5809 };
5810 state
5811 .task_registry
5812 .record_leader(&name, lease.leader_id(), &tick_key);
5813 tracing::debug!(task = %name, "Running scheduled task");
5814 state.task_registry.record_start(&name);
5815
5816 send_ws_sys_task_msg(&state, "started", &name, vec![]);
5817
5818 let start = std::time::Instant::now();
5819 let lease_ttl = lease_ttl_for_run(&lease, coordination, lease_ttl);
5820 match execute_task_result_with_optional_lease_ttl(
5821 &state,
5822 handler,
5823 start,
5824 &name,
5825 "fixed_delay",
5826 lease_ttl,
5827 )
5828 .await
5829 {
5830 Ok(duration_ms) => {
5831 state.task_registry.record_success(&name, duration_ms);
5832 crate::alerts::notify_scheduled_task_recovered(&state, &name);
5833 tracing::debug!(task = %name, "Task completed");
5834 send_ws_sys_task_msg(
5835 &state,
5836 "success",
5837 &name,
5838 vec![("duration_ms", serde_json::json!(duration_ms))],
5839 );
5840 }
5841 Err((duration_ms, error_str)) => {
5842 state
5843 .task_registry
5844 .record_failure(&name, duration_ms, &error_str);
5845 crate::alerts::notify_scheduled_task_failure(&state, &name, &error_str);
5846 tracing::warn!(task = %name, error = %error_str, "Task failed");
5847 send_ws_sys_task_msg(
5848 &state,
5849 "failure",
5850 &name,
5851 vec![
5852 ("duration_ms", serde_json::json!(duration_ms)),
5853 ("error", serde_json::json!(error_str)),
5854 ],
5855 );
5856 }
5857 }
5858
5859 if let Err(error) = lease.release().await {
5860 tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to release scheduled task lease");
5861 }
5862}
5863
5864#[allow(clippy::cognitive_complexity)]
5866async fn execute_cron_task(
5867 name: String,
5868 state: AppState,
5869 handler: crate::task::TaskHandler,
5870 coordination: crate::task::TaskCoordination,
5871 coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5872 lease_ttl: std::time::Duration,
5873 scheduled_unix_secs: u64,
5874) {
5875 let tick_key = crate::scheduler::cron_tick_key(&name, scheduled_unix_secs);
5876 let lease = match coordinator
5877 .try_acquire(&name, &tick_key, coordination)
5878 .await
5879 {
5880 Ok(Some(lease)) => lease,
5881 Ok(None) => {
5882 tracing::debug!(task = %name, tick = %tick_key, "Cron task tick already claimed");
5883 return;
5884 }
5885 Err(error) => {
5886 tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to acquire cron task lease");
5887 return;
5888 }
5889 };
5890 state
5891 .task_registry
5892 .record_leader(&name, lease.leader_id(), &tick_key);
5893 tracing::debug!(task = %name, "Running cron task");
5894 state.task_registry.record_start(&name);
5895
5896 send_ws_sys_task_msg(&state, "started", &name, vec![]);
5897
5898 let start = std::time::Instant::now();
5899 let lease_ttl = lease_ttl_for_run(&lease, coordination, lease_ttl);
5900 match execute_task_result_with_optional_lease_ttl(
5901 &state, handler, start, &name, "cron", lease_ttl,
5902 )
5903 .await
5904 {
5905 Ok(duration_ms) => {
5906 state.task_registry.record_success(&name, duration_ms);
5907 crate::alerts::notify_scheduled_task_recovered(&state, &name);
5908 tracing::debug!(task = %name, "Cron task completed");
5909 send_ws_sys_task_msg(
5910 &state,
5911 "success",
5912 &name,
5913 vec![("duration_ms", serde_json::json!(duration_ms))],
5914 );
5915 }
5916 Err((duration_ms, error_str)) => {
5917 state
5918 .task_registry
5919 .record_failure(&name, duration_ms, &error_str);
5920 crate::alerts::notify_scheduled_task_failure(&state, &name, &error_str);
5921 tracing::warn!(task = %name, error = %error_str, "Cron task failed");
5922 send_ws_sys_task_msg(
5923 &state,
5924 "failure",
5925 &name,
5926 vec![
5927 ("duration_ms", serde_json::json!(duration_ms)),
5928 ("error", serde_json::json!(error_str)),
5929 ],
5930 );
5931 }
5932 }
5933
5934 if let Err(error) = lease.release().await {
5935 tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to release cron task lease");
5936 }
5937}
5938
5939struct CronTaskSpec {
5940 name: String,
5941 expression: String,
5942 timezone: Option<String>,
5943 coordination: crate::task::TaskCoordination,
5944 handler: crate::task::TaskHandler,
5945}
5946
5947fn lease_ttl_for_run(
5948 lease: &crate::scheduler::SchedulerLease,
5949 coordination: crate::task::TaskCoordination,
5950 lease_ttl: std::time::Duration,
5951) -> Option<std::time::Duration> {
5952 (coordination == crate::task::TaskCoordination::Fleet && lease.backend() == "postgres")
5953 .then_some(lease_ttl)
5954}
5955
5956fn run_cron_scheduler(
5957 tasks: Vec<CronTaskSpec>,
5958 state: &AppState,
5959 shutdown: &tokio_util::sync::CancellationToken,
5960 coordinator: &Arc<dyn crate::scheduler::SchedulerCoordinator>,
5961 lease_ttl: std::time::Duration,
5962) {
5963 if tasks.is_empty() {
5964 return;
5965 }
5966
5967 tracing::info!(count = tasks.len(), "Cron scheduler started");
5968 for task in tasks {
5969 let state = state.clone();
5970 let coordinator = Arc::clone(coordinator);
5971 let shutdown = shutdown.child_token();
5972 tokio::spawn(async move {
5973 run_cron_task_loop(task, state, shutdown, coordinator, lease_ttl).await;
5974 });
5975 }
5976}
5977
5978#[allow(clippy::cognitive_complexity)]
5979async fn run_cron_task_loop(
5980 task: CronTaskSpec,
5981 state: AppState,
5982 shutdown: tokio_util::sync::CancellationToken,
5983 coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5984 lease_ttl: std::time::Duration,
5985) {
5986 let CronTaskSpec {
5987 name,
5988 expression,
5989 timezone,
5990 coordination,
5991 handler,
5992 } = task;
5993
5994 let cron = match expression.parse::<croner::Cron>() {
5995 Ok(cron) => cron,
5996 Err(error) => {
5997 tracing::error!(task = %name, expression = %expression, error = %error, "Failed to create cron job");
5998 return;
5999 }
6000 };
6001 let timezone = timezone
6002 .as_deref()
6003 .and_then(|timezone| {
6004 timezone.parse::<chrono_tz::Tz>().map_or_else(
6005 |_| {
6006 tracing::warn!(task = %name, timezone = %timezone, "Unrecognized timezone; falling back to UTC");
6007 None
6008 },
6009 Some,
6010 )
6011 })
6012 .unwrap_or(chrono_tz::UTC);
6013 let mut cursor = chrono::Utc::now().with_timezone(&timezone);
6014
6015 loop {
6016 let now = chrono::Utc::now().with_timezone(&timezone);
6017 let scheduled_at = match next_cron_occurrence_after(&cron, &cursor, &now) {
6018 Ok(scheduled_at) => scheduled_at,
6019 Err(error) => {
6020 tracing::error!(task = %name, expression = %expression, error = %error, "Failed to compute next cron tick");
6021 return;
6022 }
6023 };
6024 state.task_registry.record_next_run_at(
6025 &name,
6026 &scheduled_at.with_timezone(&chrono::Utc).to_rfc3339(),
6027 );
6028 let sleep_for = cron_sleep_duration_until(&scheduled_at);
6029 tokio::select! {
6030 () = shutdown.cancelled() => break,
6031 () = tokio::time::sleep(sleep_for) => {
6032 let woke_at = chrono::Utc::now().with_timezone(&timezone);
6033 match cron_occurrence_is_overdue(&cron, &scheduled_at, &woke_at) {
6034 Ok(true) => {
6035 tracing::warn!(
6036 task = %name,
6037 scheduled_at = %scheduled_at,
6038 woke_at = %woke_at,
6039 "Skipping overdue cron task tick"
6040 );
6041 cursor = woke_at;
6042 continue;
6043 }
6044 Ok(false) => {}
6045 Err(error) => {
6046 tracing::error!(task = %name, expression = %expression, error = %error, "Failed to evaluate cron tick lateness");
6047 return;
6048 }
6049 }
6050 let scheduled_unix_secs = u64::try_from(scheduled_at.timestamp()).unwrap_or_default();
6051 tokio::spawn(execute_cron_task(
6052 name.clone(),
6053 state.clone(),
6054 handler,
6055 coordination,
6056 Arc::clone(&coordinator),
6057 lease_ttl,
6058 scheduled_unix_secs,
6059 ));
6060 cursor = scheduled_at;
6061 }
6062 }
6063 }
6064}
6065
6066fn format_next_task_run_after(delay: std::time::Duration) -> String {
6067 let now = chrono::Utc::now();
6068 let Ok(delay) = chrono::TimeDelta::from_std(delay) else {
6069 return now.to_rfc3339();
6070 };
6071 (now + delay).to_rfc3339()
6072}
6073
6074fn next_cron_occurrence_after<Tz: chrono::TimeZone>(
6075 cron: &croner::Cron,
6076 cursor: &chrono::DateTime<Tz>,
6077 now: &chrono::DateTime<Tz>,
6078) -> Result<chrono::DateTime<Tz>, croner::errors::CronError> {
6079 let anchor = if cursor < now { now } else { cursor };
6080 cron.find_next_occurrence(anchor, false)
6081}
6082
6083fn cron_occurrence_is_overdue<Tz: chrono::TimeZone>(
6084 cron: &croner::Cron,
6085 scheduled_at: &chrono::DateTime<Tz>,
6086 now: &chrono::DateTime<Tz>,
6087) -> Result<bool, croner::errors::CronError> {
6088 let next_after_scheduled = cron.find_next_occurrence(scheduled_at, false)?;
6089 Ok(&next_after_scheduled <= now)
6090}
6091
6092fn cron_sleep_duration_until<Tz: chrono::TimeZone>(
6093 scheduled_at: &chrono::DateTime<Tz>,
6094) -> std::time::Duration {
6095 scheduled_at
6096 .with_timezone(&chrono::Utc)
6097 .signed_duration_since(chrono::Utc::now())
6098 .to_std()
6099 .unwrap_or_default()
6100}
6101
6102async fn run_startup_hooks(hooks: &[StartupHook], state: AppState) -> crate::AutumnResult<()> {
6103 for hook in hooks {
6104 hook(state.clone()).await?;
6105 }
6106 Ok(())
6107}
6108
6109fn run_state_initializers(initializers: Vec<StateInitializer>, state: &AppState) {
6110 for initializer in initializers {
6111 initializer(state);
6112 }
6113}
6114
6115fn synthesize_durable_listener_jobs(
6133 listeners: Vec<crate::events::ListenerInfo>,
6134 jobs: &mut Vec<crate::job::JobInfo>,
6135) -> crate::events::EventRegistry {
6136 let registry = crate::events::EventRegistry::from_listeners(listeners);
6137 jobs.extend(registry.durable_job_infos());
6138 registry
6139}
6140
6141fn finalize_event_bus(
6142 listeners: Vec<crate::events::ListenerInfo>,
6143 jobs: &mut Vec<crate::job::JobInfo>,
6144 state: &AppState,
6145) {
6146 let registry = synthesize_durable_listener_jobs(listeners, jobs);
6147 state.insert_extension(registry.clone());
6148 crate::events::init_global_event_bus(®istry, state, None);
6149}
6150
6151fn dump_jobs_manifest(
6166 cfg: &crate::config::JobQueuesConfig,
6167 mut jobs: Vec<crate::job::JobInfo>,
6168 listeners: Vec<crate::events::ListenerInfo>,
6169) -> String {
6170 synthesize_durable_listener_jobs(listeners, &mut jobs);
6171 crate::job::render_jobs_manifest(cfg, &jobs)
6172}
6173
6174fn initialize_job_runtime(
6175 jobs: Vec<crate::job::JobInfo>,
6176 state: &AppState,
6177 shutdown: &tokio_util::sync::CancellationToken,
6178 config: &crate::config::JobConfig,
6179 run_workers: bool,
6180) -> crate::AutumnResult<()> {
6181 crate::job::clear_global_job_client();
6182 if jobs.is_empty() {
6183 Ok(())
6184 } else {
6185 crate::job::start_runtime(jobs, state, shutdown, config, run_workers)
6186 }
6187}
6188
6189enum BoundListener {
6196 Tcp(tokio::net::TcpListener),
6198 #[cfg(unix)]
6200 Unix(tokio::net::UnixListener),
6201 #[cfg(feature = "tls")]
6203 Tls(crate::tls::TlsListener),
6204}
6205
6206#[cfg(feature = "tls")]
6208fn now_unix() -> i64 {
6209 std::time::SystemTime::now()
6210 .duration_since(std::time::UNIX_EPOCH)
6211 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
6212}
6213
6214#[cfg(feature = "tls")]
6216struct TlsReloadState {
6217 resolver: std::sync::Arc<crate::tls::ReloadableCertResolver>,
6218 provider: std::sync::Arc<rustls::crypto::CryptoProvider>,
6219 cert_path: std::path::PathBuf,
6220 key_path: std::path::PathBuf,
6221 interval: std::time::Duration,
6222}
6223
6224#[cfg(feature = "tls")]
6227fn build_tls_listener(
6228 tcp: tokio::net::TcpListener,
6229 cfg: &crate::config::TlsConfig,
6230 shutdown: tokio_util::sync::CancellationToken,
6231) -> Result<(crate::tls::TlsListener, TlsReloadState), crate::tls::TlsError> {
6232 let provider = crate::tls::crypto_provider();
6233 let cert_path = cfg
6237 .cert_path
6238 .as_deref()
6239 .expect("validated: static [server.tls] sets cert_path");
6240 let key_path = cfg
6241 .key_path
6242 .as_deref()
6243 .expect("validated: static [server.tls] sets key_path");
6244 let certified = crate::tls::load_certified_key(cert_path, key_path, &provider, now_unix())?;
6245 let resolver = std::sync::Arc::new(crate::tls::ReloadableCertResolver::new(certified));
6246 let server_config = crate::tls::build_server_config(
6247 std::sync::Arc::clone(&provider),
6248 std::sync::Arc::clone(&resolver),
6249 )?;
6250 let handshake_timeout = std::time::Duration::from_secs(cfg.handshake_timeout_secs.max(1));
6253 let listener = crate::tls::TlsListener::new(tcp, server_config, handshake_timeout, shutdown);
6254 let reload = TlsReloadState {
6255 resolver,
6256 provider,
6257 cert_path: cert_path.to_path_buf(),
6258 key_path: key_path.to_path_buf(),
6259 interval: std::time::Duration::from_secs(cfg.reload_interval_secs.max(1)),
6261 };
6262 Ok((listener, reload))
6263}
6264
6265#[cfg(feature = "acme")]
6268struct AcmeBindState {
6269 renewal_task: crate::acme::renewal::AcmeRenewalTask,
6270 tokens: crate::acme::challenge::Http01Tokens,
6271 http_challenge_port: u16,
6272 https_port: u16,
6273}
6274
6275#[cfg(feature = "acme")]
6280async fn build_acme_tls_listener(
6281 tcp: tokio::net::TcpListener,
6282 tls_cfg: &crate::config::TlsConfig,
6283 acme_cfg: &crate::config::AcmeConfig,
6284 https_port: u16,
6285 status: Option<crate::acme::renewal::AcmeStatus>,
6286 shutdown: tokio_util::sync::CancellationToken,
6287) -> Result<(crate::tls::TlsListener, AcmeBindState), String> {
6288 use crate::acme::store::{AcmeStore, CertId, FsAcmeStore};
6289
6290 let provider = crate::tls::crypto_provider();
6291 let cert_id = CertId::from_domains(&acme_cfg.domains);
6292 let directory_label = crate::acme::directory_label(&acme_cfg.directory);
6293 let store: std::sync::Arc<dyn AcmeStore> = std::sync::Arc::new(FsAcmeStore::new(
6294 acme_cfg.cache_dir.clone(),
6295 directory_label,
6296 ));
6297 let status = status.unwrap_or_default();
6298
6299 let (initial, serving_stored_cert) = match store.load_cert(&cert_id).await {
6302 Ok(Some(stored)) => match crate::tls::certified_key_from_pem(
6303 stored.chain_pem.as_bytes(),
6304 stored.key_pem.as_bytes(),
6305 &provider,
6306 ) {
6307 Ok(ck) => {
6308 if let Ok(not_after) =
6309 crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes())
6310 {
6311 status.set_cert_not_after(not_after);
6312 }
6313 (ck, true)
6314 }
6315 Err(e) => {
6316 tracing::warn!(
6317 "stored ACME certificate is unusable ({e}); serving a self-signed \
6318 placeholder until the renewal task issues a real one"
6319 );
6320 (acme_placeholder_key(&acme_cfg.domains, &provider)?, false)
6321 }
6322 },
6323 Ok(None) => (acme_placeholder_key(&acme_cfg.domains, &provider)?, false),
6324 Err(e) => {
6325 tracing::warn!(
6326 "failed to read the stored ACME certificate ({e}); serving a self-signed \
6327 placeholder"
6328 );
6329 (acme_placeholder_key(&acme_cfg.domains, &provider)?, false)
6330 }
6331 };
6332
6333 let resolver = std::sync::Arc::new(crate::tls::ReloadableCertResolver::new(initial));
6334 let server_config = crate::tls::build_server_config(
6335 std::sync::Arc::clone(&provider),
6336 std::sync::Arc::clone(&resolver),
6337 )
6338 .map_err(|e| e.to_string())?;
6339 let handshake_timeout = std::time::Duration::from_secs(tls_cfg.handshake_timeout_secs.max(1));
6340 let listener = crate::tls::TlsListener::new(tcp, server_config, handshake_timeout, shutdown);
6341
6342 let tokens = crate::acme::challenge::Http01Tokens::new();
6343 let renewal_task = crate::acme::renewal::AcmeRenewalTask {
6344 resolver,
6345 provider,
6346 store,
6347 cert_id,
6348 tokens: tokens.clone(),
6349 status,
6350 config: acme_cfg.clone(),
6351 serving_stored_cert,
6352 leadership_degraded: false,
6355 renew_window_misconfigured: std::sync::atomic::AtomicBool::new(false),
6356 };
6357 Ok((
6358 listener,
6359 AcmeBindState {
6360 renewal_task,
6361 tokens,
6362 http_challenge_port: acme_cfg.http_challenge_port,
6363 https_port,
6364 },
6365 ))
6366}
6367
6368#[cfg(feature = "acme")]
6370fn acme_placeholder_key(
6371 domains: &[String],
6372 provider: &rustls::crypto::CryptoProvider,
6373) -> Result<std::sync::Arc<rustls::sign::CertifiedKey>, String> {
6374 let placeholder = crate::acme::renewal::self_signed_placeholder(domains)?;
6375 crate::tls::certified_key_from_pem(
6376 placeholder.chain_pem.as_bytes(),
6377 placeholder.key_pem.as_bytes(),
6378 provider,
6379 )
6380}
6381
6382#[cfg(all(feature = "acme", feature = "reporting"))]
6387fn make_acme_reporter(
6388 reporters: Vec<std::sync::Arc<dyn crate::reporting::ErrorReporter>>,
6389) -> crate::acme::renewal::ReporterFn {
6390 std::sync::Arc::new(move |message: String| {
6391 if reporters.is_empty() {
6392 return;
6393 }
6394 let reporters = reporters.clone();
6395 tokio::spawn(async move {
6396 let event = crate::reporting::ErrorEvent {
6397 status: axum::http::StatusCode::INTERNAL_SERVER_ERROR,
6398 message,
6399 problem_type: None,
6400 request_id: None,
6401 route: Some("acme-renewal".to_owned()),
6402 method: None,
6403 panic: None,
6404 };
6405 for reporter in &reporters {
6406 reporter.report(&event).await;
6407 }
6408 });
6409 })
6410}
6411
6412#[cfg(all(feature = "acme", not(feature = "reporting")))]
6415fn make_acme_reporter() -> crate::acme::renewal::ReporterFn {
6416 std::sync::Arc::new(|_message: String| {})
6417}
6418
6419#[cfg(feature = "tls")]
6422fn tls_file_mtimes(
6423 cert: &std::path::Path,
6424 key: &std::path::Path,
6425) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>) {
6426 let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
6427 (mtime(cert), mtime(key))
6428}
6429
6430#[cfg(feature = "tls")]
6434async fn run_tls_cert_reload(state: TlsReloadState, shutdown: tokio_util::sync::CancellationToken) {
6435 let stat_mtimes = |cert: std::path::PathBuf, key: std::path::PathBuf| {
6439 tokio::task::spawn_blocking(move || tls_file_mtimes(&cert, &key))
6440 };
6441
6442 let mut last = match stat_mtimes(state.cert_path.clone(), state.key_path.clone()).await {
6443 Ok(mtimes) => mtimes,
6444 Err(e) => {
6445 tracing::warn!(error = %e, "TLS reload: initial mtime read failed; assuming unknown");
6446 (None, None)
6447 }
6448 };
6449 loop {
6450 tokio::select! {
6451 () = tokio::time::sleep(state.interval) => {}
6452 () = shutdown.cancelled() => break,
6453 }
6454
6455 let current = match stat_mtimes(state.cert_path.clone(), state.key_path.clone()).await {
6456 Ok(mtimes) => mtimes,
6457 Err(e) => {
6458 tracing::warn!(error = %e, "TLS reload: mtime read task failed; skipping tick");
6459 continue;
6460 }
6461 };
6462 if current == last {
6463 continue;
6464 }
6465
6466 let cert_path = state.cert_path.clone();
6467 let key_path = state.key_path.clone();
6468 let provider = std::sync::Arc::clone(&state.provider);
6469 let loaded = tokio::task::spawn_blocking(move || {
6470 crate::tls::load_certified_key(&cert_path, &key_path, &provider, now_unix())
6471 })
6472 .await;
6473 let loaded = match loaded {
6474 Ok(result) => result,
6475 Err(e) => {
6476 tracing::warn!(error = %e, "TLS reload: load task failed; skipping tick");
6477 continue;
6478 }
6479 };
6480
6481 match loaded {
6482 Ok(next) => {
6483 state.resolver.store(next);
6484 last = current;
6487 tracing::info!(
6488 cert = %state.cert_path.display(),
6489 "Reloaded TLS certificate after detecting a change on disk"
6490 );
6491 }
6492 Err(e) => {
6493 tracing::error!(
6494 error = %e,
6495 cert = %state.cert_path.display(),
6496 "TLS certificate reload failed; keeping the previously loaded certificate"
6497 );
6498 }
6499 }
6500 }
6501}
6502
6503#[cfg(unix)]
6510#[derive(Clone, Debug)]
6511struct UdsConnectInfo;
6512
6513#[cfg(unix)]
6514impl
6515 axum::extract::connect_info::Connected<
6516 axum::serve::IncomingStream<'_, tokio::net::UnixListener>,
6517 > for UdsConnectInfo
6518{
6519 fn connect_info(_stream: axum::serve::IncomingStream<'_, tokio::net::UnixListener>) -> Self {
6520 Self
6521 }
6522}
6523
6524#[cfg(unix)]
6534async fn stamp_loopback_connect_info(
6535 mut req: axum::extract::Request,
6536 next: axum::middleware::Next,
6537) -> axum::response::Response {
6538 if req
6539 .extensions()
6540 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
6541 .is_none()
6542 {
6543 let loopback =
6544 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 0);
6545 req.extensions_mut()
6546 .insert(axum::extract::ConnectInfo(loopback));
6547 }
6548 next.run(req).await
6549}
6550
6551fn signal_serve_ready(drain_budget_secs: u64) {
6571 let Some(path) = std::env::var_os("AUTUMN_SERVE_READY_FILE") else {
6572 return;
6573 };
6574 if path.is_empty() {
6575 return;
6576 }
6577 let path = std::path::PathBuf::from(path);
6578 let mut tmp = path.clone();
6583 tmp.as_mut_os_string().push(".tmp");
6584 if let Err(e) = std::fs::write(&tmp, drain_budget_secs.to_string())
6585 .and_then(|()| std::fs::rename(&tmp, &path))
6586 {
6587 let _ = std::fs::remove_file(&tmp);
6588 tracing::warn!(error = %e, path = %path.display(),
6589 "could not write serve readiness file");
6590 }
6591}
6592
6593#[cfg(unix)]
6604fn prepare_unix_socket_path(path: &std::path::Path) -> std::io::Result<()> {
6605 use std::os::unix::fs::FileTypeExt;
6606 match std::fs::symlink_metadata(path) {
6607 Ok(meta) if meta.file_type().is_socket() => {
6608 match std::os::unix::net::UnixStream::connect(path) {
6609 Ok(_) => Err(std::io::Error::new(
6611 std::io::ErrorKind::AddrInUse,
6612 format!(
6613 "refusing to bind unix socket: {} is already in use by a \
6614 live listener",
6615 path.display()
6616 ),
6617 )),
6618 Err(e)
6621 if matches!(
6622 e.kind(),
6623 std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
6624 ) =>
6625 {
6626 std::fs::remove_file(path)
6627 }
6628 Err(e) => Err(std::io::Error::new(
6633 std::io::ErrorKind::AddrInUse,
6634 format!(
6635 "refusing to bind unix socket: cannot determine whether {} \
6636 is live ({e}); not removing it",
6637 path.display()
6638 ),
6639 )),
6640 }
6641 }
6642 Ok(_) => Err(std::io::Error::new(
6643 std::io::ErrorKind::AlreadyExists,
6644 format!(
6645 "refusing to bind unix socket: {} exists and is not a socket",
6646 path.display()
6647 ),
6648 )),
6649 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
6650 Err(e) => Err(e),
6651 }
6652}
6653
6654async fn run_shutdown_hooks(hooks: &[ShutdownHook]) {
6655 for hook in hooks.iter().rev() {
6656 hook().await;
6657 }
6658}
6659
6660async fn run_shutdown_hooks_with_timeout(
6669 hooks: &[ShutdownHook],
6670 per_hook_budget: std::time::Duration,
6671 total_budget: std::time::Duration,
6672) {
6673 let deadline = tokio::time::Instant::now() + total_budget;
6674 for hook in hooks.iter().rev() {
6675 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
6676 if remaining.is_zero() {
6677 tracing::warn!("shutdown: total hook budget exhausted; skipping remaining hooks");
6678 break;
6679 }
6680 let timeout = remaining.min(per_hook_budget);
6681 if tokio::time::timeout(timeout, hook()).await.is_err() {
6684 tracing::warn!(
6685 per_hook_budget_ms = timeout.as_millis(),
6686 "shutdown: hook overran per-hook timeout; continuing with remaining budget"
6687 );
6688 }
6689 }
6690}
6691
6692#[allow(clippy::cognitive_complexity)]
6699fn log_startup_transparency(
6700 routes: &[Route],
6701 tasks: &[crate::task::TaskInfo],
6702 scoped_groups: &[ScopedGroup],
6703 config: &AutumnConfig,
6704) {
6705 tracing::info!(
6706 "Registered routes:{}",
6707 format_route_lines(routes, scoped_groups, config)
6708 );
6709
6710 if let Some(task_lines) = format_task_lines(tasks) {
6711 tracing::info!("Scheduled tasks:{task_lines}");
6712 }
6713
6714 tracing::info!("Active middleware: {}", format_middleware_list(config));
6715
6716 tracing::info!("Configuration:{}", format_config_summary(config));
6717}
6718
6719fn fail_fast_on_invalid_session_config(config: &AutumnConfig, has_custom_session_store: bool) {
6732 if has_custom_session_store {
6733 return;
6734 }
6735 if let Err(error) = config.session.backend_plan(config.profile.as_deref()) {
6736 eprintln!("Invalid session backend config: {error}");
6737 std::process::exit(1);
6738 }
6739}
6740
6741fn fail_fast_on_missing_encryption_keys(config: &AutumnConfig) {
6751 if let Err(diagnostic) = crate::encryption::init_attribute_encryption(config.credentials()) {
6752 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6753 if is_production {
6754 eprintln!("Attribute encryption misconfiguration: {diagnostic}");
6755 std::process::exit(1);
6756 }
6757 eprintln!(
6758 "warning: attribute encryption is not fully configured (dev): {diagnostic}\n \
6759 note: encrypted-column reads/writes will fail until keys are set; \
6760 this is a hard error in production."
6761 );
6762 }
6763}
6764
6765fn fail_fast_on_invalid_signing_secret(config: &AutumnConfig) {
6771 use crate::security::config::validate_signing_secret;
6772
6773 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6774 let secret = config.security.signing_secret.secret.as_deref();
6775
6776 if let Err(error) = validate_signing_secret(secret, is_production) {
6777 eprintln!("Invalid signing secret configuration: {error}");
6778 eprintln!(
6779 " hint: generate a secret with `openssl rand -hex 32` and set \
6780 AUTUMN_SECURITY__SIGNING_SECRET"
6781 );
6782 std::process::exit(1);
6783 }
6784
6785 if is_production {
6788 for (i, prev) in config
6789 .security
6790 .signing_secret
6791 .previous_secrets
6792 .iter()
6793 .enumerate()
6794 {
6795 if let Err(error) = validate_signing_secret(Some(prev.as_str()), true) {
6796 eprintln!("Invalid signing secret configuration: previous_secrets[{i}]: {error}");
6797 eprintln!(
6798 " hint: every previous secret must meet the same entropy requirement \
6799 as the current secret"
6800 );
6801 std::process::exit(1);
6802 }
6803 }
6804 }
6805}
6806
6807fn fail_fast_on_invalid_webhook_config(config: &AutumnConfig) {
6808 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6809 if let Err(error) = config.security.webhooks.validate(is_production) {
6810 eprintln!("Invalid signed webhook configuration: {error}");
6811 std::process::exit(1);
6812 }
6813}
6814
6815fn fail_fast_on_invalid_trusted_hosts(config: &AutumnConfig) {
6816 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6817 if !is_production {
6818 return;
6819 }
6820 let hosts: Vec<String> = config
6821 .security
6822 .trusted_hosts
6823 .hosts
6824 .iter()
6825 .map(|h| h.trim().to_owned())
6826 .filter(|h| !h.is_empty())
6827 .collect();
6828 if hosts.is_empty() {
6829 eprintln!(
6830 "[security.trusted_hosts] is required in production; set hosts = [\"example.com\"] or explicit entries"
6831 );
6832 std::process::exit(1);
6833 }
6834 if hosts.iter().any(|h| h == "*") {
6835 tracing::warn!("trusted host validation disabled via wildcard '*' in production");
6836 }
6837}
6838
6839fn fail_fast_on_invalid_idempotency_config(config: &AutumnConfig) {
6840 if !config.idempotency.enabled.unwrap_or(false) {
6841 return;
6842 }
6843 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6844 if is_production
6845 && config.idempotency.backend == crate::config::IdempotencyBackend::Memory
6846 && !config.idempotency.allow_memory_in_production
6847 {
6848 eprintln!(
6849 "The in-memory idempotency backend is not safe for multi-replica production use.\n\
6850 Set `[idempotency] backend = \"redis\"` in autumn.toml, or set \
6851 `allow_memory_in_production = true` to suppress this check."
6852 );
6853 std::process::exit(1);
6854 }
6855 #[cfg(feature = "redis")]
6856 if config.idempotency.backend == crate::config::IdempotencyBackend::Redis {
6857 let url_missing = config
6858 .idempotency
6859 .redis
6860 .url
6861 .as_deref()
6862 .is_none_or(|u| u.trim().is_empty());
6863 if url_missing {
6864 eprintln!(
6865 "Redis idempotency backend requires a connection URL.\n\
6866 Set AUTUMN_IDEMPOTENCY__REDIS__URL or `[idempotency.redis] url` in autumn.toml."
6867 );
6868 std::process::exit(1);
6869 }
6870 }
6871}
6872
6873pub(crate) fn install_webhook_registry(state: &AppState, config: &AutumnConfig) {
6874 if let Err(error) =
6875 crate::webhook::install_registry_from_config(state, &config.security.webhooks)
6876 {
6877 eprintln!("Invalid signed webhook configuration: {error}");
6878 std::process::exit(1);
6879 }
6880}
6881
6882#[cfg(feature = "storage")]
6888struct StorageBootstrap {
6889 store: crate::storage::SharedBlobStore,
6890 serving: Option<axum::Router<AppState>>,
6891}
6892
6893#[cfg(feature = "storage")]
6894impl StorageBootstrap {
6895 fn install(self, state: &AppState) -> Option<axum::Router<AppState>> {
6899 state.insert_extension::<crate::storage::BlobStoreState>(
6900 crate::storage::BlobStoreState::new(self.store),
6901 );
6902 self.serving
6903 }
6904}
6905
6906#[cfg(feature = "storage")]
6914#[allow(clippy::too_many_lines)] fn preflight_storage(config: &AutumnConfig) -> Option<StorageBootstrap> {
6916 use crate::storage::StorageBackendPlan;
6917
6918 let plan = config
6919 .storage
6920 .backend_plan(config.profile.as_deref())
6921 .unwrap_or_else(|error| {
6922 tracing::error!(%error, "invalid storage backend config; aborting startup");
6928 std::process::exit(1);
6929 });
6930
6931 match plan {
6932 StorageBackendPlan::Disabled => None,
6933 StorageBackendPlan::Local {
6934 provider_id,
6935 root,
6936 mount_path,
6937 default_url_expiry_secs,
6938 warn_in_production,
6939 } => Some(bootstrap_local_storage(
6940 config,
6941 &provider_id,
6942 &root,
6943 &mount_path,
6944 default_url_expiry_secs,
6945 warn_in_production,
6946 )),
6947 StorageBackendPlan::S3 { .. } => {
6948 tracing::error!(
6953 "storage.backend=s3 requires the `autumn-storage-s3` plugin. \
6954 Add it to your Cargo.toml, build an S3BlobStore from your config, \
6955 and call `.with_blob_store(store)` on your AppBuilder. \
6956 Aborting startup."
6957 );
6958 std::process::exit(1);
6959 }
6960 }
6961}
6962
6963#[cfg(feature = "storage")]
6964fn bootstrap_local_storage(
6965 config: &AutumnConfig,
6966 provider_id: &str,
6967 root: &std::path::Path,
6968 mount_path: &str,
6969 default_url_expiry_secs: u64,
6970 warn_in_production: bool,
6971) -> StorageBootstrap {
6972 use crate::storage::{LocalBlobStore, SharedBlobStore, local::SigningKey};
6973
6974 if warn_in_production {
6975 tracing::warn!(
6976 "prod profile is using the local-disk blob store; \
6977 bytes won't survive replica turnover. Set \
6978 storage.backend=s3 or storage.allow_local_in_production=true \
6979 to acknowledge"
6980 );
6981 }
6982
6983 let (signing_key, previous_signing_keys) = config
6988 .security
6989 .signing_secret
6990 .secret
6991 .as_deref()
6992 .filter(|s| !s.is_empty())
6993 .map_or_else(
6994 || {
6995 config
6996 .storage
6997 .local
6998 .signing_key
6999 .as_deref()
7000 .filter(|s| !s.is_empty())
7001 .map_or_else(
7002 || {
7003 if matches!(config.profile.as_deref(), Some("prod" | "production")) {
7004 tracing::warn!(
7005 "no signing secret configured in prod; blob URL signatures \
7006 won't survive a process restart. Set \
7007 AUTUMN_SECURITY__SIGNING_SECRET."
7008 );
7009 }
7010 (SigningKey::random(), vec![])
7011 },
7012 |legacy| (SigningKey::new(legacy.as_bytes().to_vec()), vec![]),
7013 )
7014 },
7015 |secret| {
7016 let current = SigningKey::new(secret.as_bytes().to_vec());
7017 let previous = config
7018 .security
7019 .signing_secret
7020 .previous_secrets
7021 .iter()
7022 .map(|s| SigningKey::new(s.as_bytes().to_vec()))
7023 .collect::<Vec<_>>();
7024 (current, previous)
7025 },
7026 );
7027
7028 let store = match LocalBlobStore::new(
7029 provider_id.to_string(),
7030 root.to_path_buf(),
7031 mount_path.to_string(),
7032 std::time::Duration::from_secs(default_url_expiry_secs),
7033 signing_key,
7034 previous_signing_keys,
7035 ) {
7036 Ok(store) => store,
7037 Err(err) => {
7038 tracing::error!(
7043 error = %err,
7044 root = %root.display(),
7045 "failed to initialize local blob store; aborting startup"
7046 );
7047 std::process::exit(1);
7048 }
7049 };
7050
7051 let serving = crate::storage::local::serve_router(&store);
7052 let arc: SharedBlobStore = std::sync::Arc::new(store);
7053
7054 tracing::info!(
7055 provider = %provider_id,
7056 root = %root.display(),
7057 mount = %mount_path,
7058 "Local blob store mounted"
7059 );
7060
7061 StorageBootstrap {
7062 store: arc,
7063 serving: Some(serving),
7064 }
7065}
7066async fn load_config_and_telemetry(
7067 config_loader: Option<ConfigLoaderFactory>,
7068 telemetry_provider: Option<Box<dyn crate::telemetry::TelemetryProvider>>,
7069 plugin_config_roots: BTreeSet<String>,
7070) -> (AutumnConfig, crate::telemetry::TelemetryGuard) {
7071 let mut config = match config_loader {
7081 Some(factory) => factory().await,
7082 None => {
7083 crate::config::TomlEnvConfigLoader::new()
7084 .with_plugin_config_roots(plugin_config_roots)
7085 .load()
7086 .await
7087 }
7088 }
7089 .unwrap_or_else(|e| {
7090 eprintln!("Failed to load configuration: {e}");
7091 std::process::exit(1);
7092 });
7093
7094 if let Ok(forced) = std::env::var("AUTUMN_SERVE_FORCE_UNIX_SOCKET")
7102 && !forced.is_empty()
7103 {
7104 config.server.unix_socket = Some(forced);
7105 }
7106
7107 let provider: Box<dyn crate::telemetry::TelemetryProvider> = telemetry_provider
7110 .unwrap_or_else(|| Box::new(crate::telemetry::TracingOtlpTelemetryProvider::new()));
7111 let telemetry_guard = provider
7112 .init(&config.log, &config.telemetry, config.profile.as_deref())
7113 .unwrap_or_else(|error| {
7114 eprintln!("Failed to initialize telemetry: {error}");
7115 std::process::exit(1);
7116 });
7117
7118 (config, telemetry_guard)
7119}
7120
7121#[cfg(feature = "embed-assets")]
7126fn register_embedded_static_dir(embedded_static: Option<crate::assets::EmbeddedStaticDir>) {
7127 if let Some(dir) = embedded_static {
7128 crate::assets::register_embedded_static(dir);
7129 }
7130}
7131
7132#[cfg(all(feature = "embed-assets", feature = "i18n"))]
7136fn embedded_i18n_bundle(
7137 explicit: Option<Arc<crate::i18n::Bundle>>,
7138 embedded_locales: Option<&'static include_dir::Dir<'static>>,
7139 config: &AutumnConfig,
7140) -> Option<Arc<crate::i18n::Bundle>> {
7141 explicit.or_else(|| {
7142 embedded_locales.map(|dir| {
7143 Arc::new(
7144 crate::i18n::Bundle::load_from_embedded(dir, &config.i18n)
7145 .unwrap_or_else(|e| panic!("embedded_locales: {e}")),
7146 )
7147 })
7148 })
7149}
7150
7151#[cfg(feature = "i18n")]
7152fn resolve_i18n_bundle(
7153 explicit_bundle: Option<Arc<crate::i18n::Bundle>>,
7154 auto_load: bool,
7155 config: &AutumnConfig,
7156 env: &dyn crate::config::Env,
7157) -> Option<Arc<crate::i18n::Bundle>> {
7158 if explicit_bundle.is_some() {
7159 return explicit_bundle;
7160 }
7161 if !auto_load {
7162 return None;
7163 }
7164
7165 let dir = project_dir(&config.i18n.dir, env);
7166 Some(Arc::new(
7167 crate::i18n::Bundle::load_from_dir(&dir, &config.i18n)
7168 .unwrap_or_else(|e| panic!("i18n_auto: {e}")),
7169 ))
7170}
7171
7172#[cfg(feature = "i18n")]
7173fn install_i18n_bundle_layer(
7174 mut custom_layers: Vec<CustomLayerRegistration>,
7175 state: &AppState,
7176 bundle: Option<Arc<crate::i18n::Bundle>>,
7177) -> Vec<CustomLayerRegistration> {
7178 let Some(bundle) = bundle else {
7179 return custom_layers;
7180 };
7181
7182 tracing::info!(
7183 locales = ?bundle.locales(),
7184 default = bundle.default_locale(),
7185 "i18n bundle loaded"
7186 );
7187 state.insert_extension::<Arc<crate::i18n::Bundle>>(bundle.clone());
7188 let ext_layer = axum::Extension(bundle);
7192 custom_layers.push(CustomLayerRegistration {
7193 type_id: TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
7194 type_name: std::any::type_name::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
7195 apply: Box::new(move |router| router.layer(ext_layer)),
7196 });
7197 custom_layers
7198}
7199
7200#[cfg(feature = "db")]
7201struct DatabaseBootstrap {
7202 topology: Option<crate::db::DatabaseTopology>,
7203 shards: Option<crate::sharding::ShardSet>,
7204 replica_readiness: Option<crate::migrate::ReplicaMigrationReadiness>,
7205 replica_migration_check: Option<(String, String)>,
7206}
7207
7208#[cfg(feature = "db")]
7221async fn resolve_shard_set(
7222 config: &AutumnConfig,
7223 shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
7224 shard_provider: Option<ShardProviderFactory>,
7225 directory_routing_enabled: bool,
7226 spawn_directory_listener: bool,
7227 topology: Option<&crate::db::DatabaseTopology>,
7228) -> Result<Option<crate::sharding::ShardSet>, String> {
7229 if !config.database.has_shards() {
7230 return Ok(None);
7231 }
7232 let router: Arc<dyn crate::sharding::ShardRouter> = match shard_router {
7233 Some(explicit) => explicit,
7234 None if directory_routing_enabled => {
7235 let control_primary = topology
7236 .map(crate::db::DatabaseTopology::primary)
7237 .ok_or_else(|| {
7238 "directory_shard_router is enabled but no control database is configured. \
7239 The directory router needs a control `database.primary_url`/`url` to read \
7240 the tenant→shard directory. Set one, or disable directory routing to use \
7241 the hash router."
7242 .to_owned()
7243 })?;
7244 let control_max = control_primary.status().max_size;
7252 if control_max < 2 {
7253 return Err(format!(
7254 "directory_shard_router requires a control database pool of at least 2 \
7255 connections, but the configured maximum is {control_max}. Directory \
7256 routing checks out a second control connection during extraction to \
7257 resolve the tenant→shard key, which deadlocks a pool sized to 1 when a \
7258 handler already holds a control connection (e.g. `Db` + `ShardedDb`). \
7259 Increase the control pool size (database.pool.max_size), or disable \
7260 directory routing to use the hash router."
7261 ));
7262 }
7263 let timeout_ms = config.database.statement_timeout.map_or(0, |d| {
7266 u64::try_from(d.as_millis())
7267 .unwrap_or(i32::MAX as u64)
7268 .min(i32::MAX as u64)
7269 });
7270 let dir_router = Arc::new(
7271 crate::sharding::DirectoryShardRouter::new(control_primary.clone())
7272 .with_statement_timeout_ms(timeout_ms),
7273 );
7274 if spawn_directory_listener {
7280 if let Some(control_url) = topology
7286 .and_then(crate::db::DatabaseTopology::migration_url)
7287 .or_else(|| config.database.effective_primary_url())
7288 {
7289 drop(
7293 crate::sharding::DirectoryShardRouter::spawn_invalidation_listener(
7294 Arc::clone(&dir_router),
7295 control_url.to_owned(),
7296 crate::sharding::DEFAULT_DIRECTORY_INVALIDATION_SWEEP_INTERVAL,
7297 ),
7298 );
7299 } else {
7300 tracing::warn!(
7311 "directory shard routing is enabled but no control database URL is \
7312 configured (database.primary_url/url is unset, e.g. a custom \
7313 DatabasePoolProvider supplied the control pool); the cache-\
7314 invalidation LISTEN/NOTIFY task cannot be started, so directory \
7315 re-pins will only take effect after the cache TTL expires rather \
7316 than fleet-wide on commit"
7317 );
7318 }
7319 }
7320 dir_router
7321 }
7322 None => Arc::new(crate::sharding::HashShardRouter),
7323 };
7324 let set = match shard_provider {
7325 Some(factory) => {
7326 let topologies = factory(config.database.clone())
7327 .await
7328 .map_err(|e| format!("Failed to create shard pools: {e}"))?;
7329 #[cfg(feature = "sqlite")]
7338 crate::db::reject_sqlite_statement_timeout(config.database.statement_timeout)
7339 .map_err(|e| format!("Failed to create shard pools: {e}"))?;
7340 crate::sharding::build_shard_set(&config.database, topologies, router)
7341 }
7342 None => crate::sharding::create_shard_set(&config.database, router)
7343 .map(|set| set.expect("has_shards() checked above")),
7344 }
7345 .map_err(|e| format!("Failed to configure shards: {e}"))?;
7346 Ok(Some(set))
7347}
7348
7349#[cfg(feature = "db")]
7350#[allow(clippy::too_many_lines)]
7353async fn setup_database(
7354 config: &AutumnConfig,
7355 migrations: Vec<crate::migrate::EmbeddedMigrations>,
7356 pool_provider: Option<PoolProviderFactory>,
7357 shard_provider: Option<ShardProviderFactory>,
7358 shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
7359 directory_shard_router: bool,
7360 hook_queue_migration_mode: RepositoryCommitHookQueueMigrationMode,
7361) -> Result<DatabaseBootstrap, String> {
7362 let migrations = migrations_with_repository_framework_migrations(
7363 migrations,
7364 crate::repository_commit_hooks::has_repository_commit_hook_descriptors(),
7365 crate::version_history::has_versioned_repository_descriptors(),
7366 hook_queue_migration_mode,
7367 );
7368 let use_directory_router = shard_router.is_none()
7376 && (directory_shard_router || config.database.directory_shard_router);
7377 let directory_migration_required = directory_migration_is_required(
7385 use_directory_router,
7386 config.database.has_shards(),
7387 hook_queue_migration_mode,
7388 );
7389 let shard_map_migration_required =
7390 shard_map_migration_is_required(config.database.has_shards(), hook_queue_migration_mode);
7391 let check_replica_migrations = !migrations.is_empty();
7392 let topology = match pool_provider {
7393 Some(factory) => factory(config.database.clone()).await,
7394 None => crate::db::create_topology(&config.database),
7395 }
7396 .map_err(|e| format!("Failed to create database pool: {e}"))?;
7397 #[cfg(feature = "sqlite")]
7417 if topology.is_some() {
7418 crate::db::reject_sqlite_statement_timeout(config.database.statement_timeout)
7419 .map_err(|e| format!("Failed to create database pool: {e}"))?;
7420 }
7421
7422 let runtime_boot = hook_queue_migration_mode == RepositoryCommitHookQueueMigrationMode::Runtime;
7425 let shards = match resolve_shard_set(
7426 config,
7427 shard_router,
7428 shard_provider,
7429 use_directory_router,
7430 runtime_boot,
7431 topology.as_ref(),
7432 )
7433 .await
7434 {
7435 Ok(shards) => shards,
7436 Err(e) => {
7437 #[cfg(feature = "managed-pg")]
7443 crate::managed_pg::emergency_stop_async().await;
7444 return Err(e);
7445 }
7446 };
7447
7448 let provider_migration_url = topology
7457 .as_ref()
7458 .and_then(|t| t.migration_url())
7459 .map(str::to_owned);
7460
7461 #[cfg(feature = "sqlite")]
7473 let sqlite_guard_shard_urls: Vec<&str> = if shards.is_some() {
7474 config
7475 .database
7476 .shards
7477 .iter()
7478 .map(|shard| shard.primary_url.as_str())
7479 .collect()
7480 } else {
7481 Vec::new()
7482 };
7483 #[cfg(feature = "sqlite")]
7484 #[allow(clippy::question_mark)] if let Err(e) = sqlite_sharding_unsupported_guard(
7486 if topology.is_some() {
7487 provider_migration_url
7488 .as_deref()
7489 .or_else(|| config.database.effective_primary_url())
7490 } else {
7491 None
7492 },
7493 directory_migration_required
7494 || shard_map_migration_required
7495 || config.database.has_shards(),
7496 &sqlite_guard_shard_urls,
7497 ) {
7498 #[cfg(feature = "managed-pg")]
7499 crate::managed_pg::emergency_stop_async().await;
7500 return Err(e);
7501 }
7502
7503 run_startup_migrations(
7504 config,
7505 topology.is_some(),
7506 shards.is_some(),
7507 provider_migration_url,
7508 migrations,
7509 directory_migration_required,
7510 shard_map_migration_required,
7511 )
7512 .await;
7513
7514 let (replica_readiness, replica_migration_check) = if topology
7515 .as_ref()
7516 .is_some_and(|topology| check_replica_migrations && topology.replica().is_some())
7517 {
7518 match (
7519 config.database.effective_primary_url(),
7520 config.database.replica_url.as_deref(),
7521 ) {
7522 (Some(primary_url), Some(replica_url)) => {
7523 let primary_url = primary_url.to_owned();
7524 let replica_url = replica_url.to_owned();
7525 let readiness = crate::migrate::check_replica_migration_readiness_blocking(
7526 primary_url.clone(),
7527 replica_url.clone(),
7528 )
7529 .await;
7530 (Some(readiness), Some((primary_url, replica_url)))
7531 }
7532 _ => (None, None),
7533 }
7534 } else {
7535 (None, None)
7536 };
7537
7538 if check_replica_migrations && let Some(set) = &shards {
7539 check_shard_replica_migration_parity(config, set).await;
7540 }
7541
7542 #[allow(clippy::question_mark)]
7547 if let Err(e) = Box::pin(enforce_shard_map_guard(
7548 config,
7549 topology.as_ref(),
7550 runtime_boot,
7551 ))
7552 .await
7553 {
7554 #[cfg(feature = "managed-pg")]
7557 crate::managed_pg::emergency_stop_async().await;
7558 return Err(e);
7559 }
7560
7561 Ok(DatabaseBootstrap {
7562 topology,
7563 shards,
7564 replica_readiness,
7565 replica_migration_check,
7566 })
7567}
7568
7569#[cfg(feature = "db")]
7587fn apply_pending_or_exit(
7588 database_url: &str,
7589 migrations: &crate::migrate::EmbeddedMigrations,
7590 target: &str,
7591) -> usize {
7592 match crate::migrate::run_pending_locked(
7593 database_url,
7594 crate::migrate::EmbeddedMigrationsRef(migrations),
7595 None,
7596 ) {
7597 Ok(result) => result.applied.len(),
7598 Err(error) => {
7599 let reason = match error {
7600 crate::migrate::MigrationError::Connection(_) => {
7601 "could not connect to the database"
7602 }
7603 crate::migrate::MigrationError::Migration(_) => "a migration failed to apply",
7604 _ => "migration error",
7605 };
7606 eprintln!("autumn migrate: {reason} (target {target})");
7607 #[cfg(feature = "managed-pg")]
7610 crate::managed_pg::emergency_stop();
7611 std::process::exit(1);
7612 }
7613 }
7614}
7615
7616#[cfg(feature = "sqlite")]
7627fn apply_pending_sqlite_or_exit(
7628 database_url: &str,
7629 migrations: &crate::migrate::EmbeddedMigrations,
7630 target: &str,
7631) -> usize {
7632 if let Some(err) = crate::migrate::reject_in_memory_migrations(
7639 database_url,
7640 &crate::migrate::EmbeddedMigrationsRef(migrations),
7641 ) {
7642 eprintln!("autumn migrate: {err} (target {target})");
7643 std::process::exit(1);
7644 }
7645 match crate::migrate::run_pending_sqlite(
7646 database_url,
7647 crate::migrate::EmbeddedMigrationsRef(migrations),
7648 ) {
7649 Ok(result) => result.applied.len(),
7650 Err(error) => {
7651 let reason = match error {
7652 crate::migrate::MigrationError::Connection(_) => {
7653 "could not connect to the database"
7654 }
7655 crate::migrate::MigrationError::Migration(_) => "a migration failed to apply",
7656 _ => "migration error",
7657 };
7658 eprintln!("autumn migrate: {reason} (target {target})");
7659 std::process::exit(1);
7660 }
7661 }
7662}
7663
7664#[cfg(feature = "sqlite")]
7695fn sqlite_sharding_unsupported_guard(
7696 control_url: Option<&str>,
7697 control_sharding_required: bool,
7698 shard_urls: &[&str],
7699) -> Result<(), String> {
7700 fn is_sqlite(url: &str) -> bool {
7701 crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Sqlite)
7702 }
7703 if control_url.is_some_and(is_sqlite) && control_sharding_required {
7704 return Err(
7705 "SQLite deployments do not support sharding. The configured sqlite:// control target \
7706 has sharding enabled (shards and/or the directory/shard-map control migrations), \
7707 which is a Postgres-only capability \u{2014} remove the shard configuration to run \
7708 on SQLite, or use a Postgres control database. Tracking: #1614."
7709 .to_owned(),
7710 );
7711 }
7712 if shard_urls.iter().copied().any(is_sqlite) {
7713 return Err(
7714 "SQLite deployments do not support sharding. A configured shard targets a SQLite \
7715 database, and per-shard migration/fan-out is a Postgres-only capability \u{2014} \
7716 remove the SQLite shard configuration to run on SQLite, or use Postgres shard \
7717 targets. Tracking: #1614."
7718 .to_owned(),
7719 );
7720 }
7721 Ok(())
7722}
7723
7724#[cfg(all(test, feature = "sqlite"))]
7725mod sqlite_sharding_unsupported_guard_tests {
7726 use super::sqlite_sharding_unsupported_guard;
7727
7728 #[test]
7729 fn sqlite_control_target_with_sharding_fails_fast() {
7730 for url in [
7733 "sqlite:///var/lib/app.db",
7734 "sqlite://./relative.db",
7735 "sqlite::memory:",
7736 ] {
7737 let err = sqlite_sharding_unsupported_guard(Some(url), true, &[])
7738 .expect_err("sqlite control target + sharding must be rejected");
7739 assert!(
7740 err.contains("do not support sharding"),
7741 "message must name the sharding situation clearly: {err}"
7742 );
7743 assert!(
7744 err.contains("#1614"),
7745 "message must point at the tracking issue: {err}"
7746 );
7747 }
7748 }
7749
7750 #[test]
7751 fn sqlite_control_target_without_sharding_boots() {
7752 for url in [
7755 "sqlite:///var/lib/app.db",
7756 "sqlite://./relative.db",
7757 "sqlite::memory:",
7758 ] {
7759 assert!(
7760 sqlite_sharding_unsupported_guard(Some(url), false, &[]).is_ok(),
7761 "sqlite target without sharding must boot (migrations now applied): {url}"
7762 );
7763 }
7764 }
7765
7766 #[test]
7767 fn postgres_target_is_unchanged() {
7768 for url in [
7770 "postgres://u@h/db",
7771 "postgresql://user:pass@db:5432/app",
7772 "host=db user=app sslmode=require",
7773 ] {
7774 assert!(
7775 sqlite_sharding_unsupported_guard(Some(url), true, &[]).is_ok(),
7776 "postgres target must never be gated: {url}"
7777 );
7778 assert!(
7779 sqlite_sharding_unsupported_guard(Some(url), false, &[]).is_ok(),
7780 "postgres target must never be gated: {url}"
7781 );
7782 }
7783 }
7784
7785 #[test]
7786 fn absent_control_url_boots() {
7787 assert!(sqlite_sharding_unsupported_guard(None, true, &[]).is_ok());
7789 assert!(sqlite_sharding_unsupported_guard(None, false, &[]).is_ok());
7790 }
7791
7792 #[test]
7793 fn sqlite_shard_target_fails_fast() {
7794 for shards in [
7799 &["sqlite:///var/lib/shard0.db"][..],
7800 &["sqlite:///var/lib/shard0.db", "postgres://u@h/shard1"][..],
7801 ] {
7802 let err = sqlite_sharding_unsupported_guard(None, false, shards)
7803 .expect_err("a sqlite shard target must be rejected");
7804 assert!(
7805 err.contains("do not support sharding") && err.contains("shard"),
7806 "message must name the SQLite shard situation clearly: {err}"
7807 );
7808 assert!(
7809 err.contains("#1614"),
7810 "message must point at the tracking issue: {err}"
7811 );
7812 }
7813 }
7814
7815 #[test]
7816 fn postgres_shard_targets_are_unchanged() {
7817 assert!(
7819 sqlite_sharding_unsupported_guard(
7820 Some("postgres://u@h/control"),
7821 true,
7822 &["postgres://u@h/shard0", "postgres://u@h/shard1"],
7823 )
7824 .is_ok(),
7825 "all-postgres shard targets must never be gated"
7826 );
7827 }
7828
7829 #[test]
7830 fn migrate_only_mode_reuses_the_boot_guard_for_sqlite_targets() {
7831 let err = sqlite_sharding_unsupported_guard(Some("sqlite:///var/lib/app.db"), true, &[])
7839 .expect_err("sqlite migrate control target with sharding must be rejected");
7840 assert!(
7841 err.contains("do not support sharding") && err.contains("#1614"),
7842 "migrate-only sqlite control error must be the actionable sharding message: {err}"
7843 );
7844
7845 let shard_err = sqlite_sharding_unsupported_guard(
7847 Some("postgres://u@h/control"),
7848 true,
7849 &["sqlite:///var/lib/shard0.db"],
7850 )
7851 .expect_err("sqlite migrate shard target must be rejected");
7852 assert!(
7853 shard_err.contains("do not support sharding") && shard_err.contains("shard"),
7854 "migrate-only sqlite shard error must be the actionable sharding message: {shard_err}"
7855 );
7856
7857 assert!(
7859 sqlite_sharding_unsupported_guard(
7860 Some("postgres://u@h/control"),
7861 true,
7862 &["postgres://u@h/shard0"],
7863 )
7864 .is_ok(),
7865 "an all-postgres migrate configuration must proceed unchanged"
7866 );
7867
7868 assert!(
7871 sqlite_sharding_unsupported_guard(Some("sqlite:///var/lib/app.db"), false, &[]).is_ok(),
7872 "sqlite control target without sharding must never be gated"
7873 );
7874 }
7875}
7876
7877#[cfg(feature = "db")]
7878#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
7879async fn run_startup_migrations(
7880 config: &AutumnConfig,
7881 control_configured: bool,
7882 shards_configured: bool,
7883 provider_migration_url: Option<String>,
7884 migrations: Vec<crate::migrate::EmbeddedMigrations>,
7885 directory_migration_required: bool,
7886 shard_map_migration_required: bool,
7887) {
7888 let control_url = if control_configured {
7889 provider_migration_url
7895 .or_else(|| config.database.effective_primary_url().map(str::to_owned))
7896 } else {
7897 None
7898 };
7899 let shard_targets: Vec<(String, String)> = if shards_configured {
7900 config
7901 .database
7902 .shards
7903 .iter()
7904 .map(|shard| (format!("shard:{}", shard.name), shard.primary_url.clone()))
7905 .collect()
7906 } else {
7907 Vec::new()
7908 };
7909 let profile = config.profile.clone();
7910 let auto_in_prod = config.database.auto_migrate_in_production;
7911 let migration_result = tokio::task::spawn_blocking(move || {
7912 #[cfg(feature = "sqlite")]
7921 if let Some(url) = control_url.as_deref()
7922 && crate::config::DatabaseBackend::detect(url)
7923 == Some(crate::config::DatabaseBackend::Sqlite)
7924 {
7925 for mig in &migrations {
7926 crate::migrate::auto_migrate_sqlite(
7927 url,
7928 profile.as_deref(),
7929 auto_in_prod,
7930 mig,
7931 "control",
7932 );
7933 }
7934 return;
7935 }
7936
7937 if let Some(url) = control_url {
7938 for mig in &migrations {
7939 crate::migrate::auto_migrate(
7940 &url,
7941 profile.as_deref(),
7942 auto_in_prod,
7943 mig,
7944 "control",
7945 );
7946 }
7947 if directory_migration_required {
7950 crate::migrate::auto_migrate(
7951 &url,
7952 profile.as_deref(),
7953 auto_in_prod,
7954 &crate::sharding::SHARD_DIRECTORY_MIGRATIONS,
7955 "control",
7956 );
7957 }
7958 if shard_map_migration_required {
7963 crate::migrate::auto_migrate(
7964 &url,
7965 profile.as_deref(),
7966 true,
7967 &crate::sharding::SHARD_MAP_MIGRATIONS,
7968 "control",
7969 );
7970 }
7971 }
7972 for (target, url) in &shard_targets {
7979 for mig in migrations
7980 .iter()
7981 .filter(|mig| !migration_set_is_control_framework(mig))
7982 {
7983 crate::migrate::auto_migrate(url, profile.as_deref(), auto_in_prod, mig, target);
7984 }
7985 }
7986 })
7987 .await;
7988 if let Err(e) = migration_result {
7989 tracing::error!(error = %e, "Migration task panicked");
7990 #[cfg(feature = "managed-pg")]
7995 crate::managed_pg::emergency_stop_async().await;
7996 std::process::exit(1);
7997 }
7998}
7999
8000#[cfg(feature = "db")]
8004async fn check_shard_replica_migration_parity(
8005 config: &AutumnConfig,
8006 set: &crate::sharding::ShardSet,
8007) {
8008 for (shard_config, shard) in config.database.shards.iter().zip(set.iter()) {
8009 let Some(replica_url) = shard_config.replica_url.as_deref() else {
8010 continue;
8011 };
8012 shard
8016 .runtime()
8017 .configure_migration_check(shard_config.primary_url.clone(), replica_url.to_owned());
8018 let _ = shard.runtime().parity_check_due();
8019 let readiness = crate::migrate::check_replica_migration_readiness_blocking(
8020 shard_config.primary_url.clone(),
8021 replica_url.to_owned(),
8022 )
8023 .await;
8024 if readiness.is_ready() {
8025 shard.runtime().mark_replica_migrations_ready();
8026 } else if let Some(detail) = readiness.detail() {
8027 tracing::warn!(
8028 shard = %shard.name(),
8029 detail = %detail,
8030 "shard replica migrations are not ready"
8031 );
8032 shard.runtime().mark_replica_migrations_unready(detail);
8033 }
8034 }
8035}
8036
8037#[cfg(feature = "db")]
8038const REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION: &str =
8039 "20260515000000_create_repository_commit_hook_queue";
8040
8041#[cfg(feature = "db")]
8042const VERSION_HISTORY_MIGRATION: &str = "20260526000000_create_version_history";
8043
8044#[cfg(feature = "db")]
8051const fn directory_migration_is_required(
8052 directory_routing_enabled: bool,
8053 has_shards: bool,
8054 mode: RepositoryCommitHookQueueMigrationMode,
8055) -> bool {
8056 directory_routing_enabled
8057 && has_shards
8058 && matches!(mode, RepositoryCommitHookQueueMigrationMode::Runtime)
8059}
8060
8061#[cfg(feature = "db")]
8069const fn shard_map_migration_is_required(
8070 has_shards: bool,
8071 mode: RepositoryCommitHookQueueMigrationMode,
8072) -> bool {
8073 has_shards && matches!(mode, RepositoryCommitHookQueueMigrationMode::Runtime)
8074}
8075
8076#[cfg(feature = "db")]
8078#[derive(diesel::QueryableByName)]
8079struct ShardMapRow {
8080 #[diesel(sql_type = diesel::sql_types::Text)]
8081 shard_name: String,
8082 #[diesel(sql_type = diesel::sql_types::Text)]
8083 slots: String,
8084}
8085
8086#[cfg(feature = "db")]
8099pub async fn run_shard_map_guard(
8100 control_pool: &deadpool::managed::Pool<
8101 diesel_async::pooled_connection::AsyncDieselConnectionManager<
8102 diesel_async::AsyncPgConnection,
8103 >,
8104 >,
8105 computed: &[crate::config::ShardSlotAssignment],
8106 auto_split: bool,
8107) -> Result<(), String> {
8108 use diesel_async::RunQueryDsl as _;
8109
8110 if !auto_split {
8111 return Ok(());
8112 }
8113
8114 let mut conn = match control_pool.get().await {
8115 Ok(conn) => conn,
8116 Err(e) => {
8117 return Err(format!(
8118 "shard-map guard could not acquire a control connection: {e} — \
8119 ensure the control database is reachable to enforce topology \
8120 change detection"
8121 ));
8122 }
8123 };
8124
8125 let rows: Vec<ShardMapRow> = match diesel::sql_query(
8126 "SELECT shard_name, slots FROM _autumn_shard_map ORDER BY shard_name",
8127 )
8128 .load::<ShardMapRow>(&mut conn)
8129 .await
8130 {
8131 Ok(rows) => rows,
8132 Err(e) => {
8133 return Err(format!(
8134 "shard-map guard could not read _autumn_shard_map: {e} — \
8135 run `autumn migrate` to create the control schema before \
8136 starting with auto-split shards"
8137 ));
8138 }
8139 };
8140
8141 let stored: Vec<crate::config::ShardSlotAssignment> = rows
8142 .into_iter()
8143 .map(|r| crate::config::ShardSlotAssignment {
8144 name: r.shard_name,
8145 ranges: r.slots,
8146 })
8147 .collect();
8148 let stored_opt = if stored.is_empty() {
8149 None
8150 } else {
8151 Some(stored.as_slice())
8152 };
8153
8154 crate::config::check_stored_slot_map(auto_split, computed, stored_opt)?;
8155
8156 if stored.is_empty() {
8160 use diesel_async::AsyncConnection as _;
8161 let assignments: Vec<_> = computed.to_vec();
8162 conn.transaction::<(), diesel::result::Error, _>(async move |conn| {
8163 for assignment in &assignments {
8164 diesel::sql_query(
8165 "INSERT INTO _autumn_shard_map (shard_name, slots) VALUES ($1, $2) \
8166 ON CONFLICT (shard_name) DO UPDATE \
8167 SET slots = EXCLUDED.slots, updated_at = NOW()",
8168 )
8169 .bind::<diesel::sql_types::Text, _>(&assignment.name)
8170 .bind::<diesel::sql_types::Text, _>(&assignment.ranges)
8171 .execute(conn)
8172 .await?;
8173 }
8174 Ok(())
8175 })
8176 .await
8177 .map_err(|e| format!("shard-map guard could not persist map: {e}"))?;
8178 }
8179
8180 Ok(())
8181}
8182
8183#[cfg(all(feature = "db", feature = "sqlite"))]
8197#[allow(clippy::unused_async)]
8198async fn enforce_shard_map_guard(
8199 config: &AutumnConfig,
8200 topology: Option<&crate::db::DatabaseTopology>,
8201 runtime_boot: bool,
8202) -> Result<(), String> {
8203 let _ = (config, topology, runtime_boot);
8204 Ok(())
8205}
8206
8207#[cfg(all(feature = "db", not(feature = "sqlite")))]
8208async fn enforce_shard_map_guard(
8209 config: &AutumnConfig,
8210 topology: Option<&crate::db::DatabaseTopology>,
8211 runtime_boot: bool,
8212) -> Result<(), String> {
8213 if !runtime_boot || !config.database.has_shards() {
8214 return Ok(());
8215 }
8216 let Some(topology) = topology else {
8217 return Ok(());
8218 };
8219 if !config.database.shards_auto_split() {
8220 return Ok(());
8221 }
8222 let computed = config
8223 .database
8224 .resolved_shard_assignments()
8225 .map_err(|e| format!("shard-map guard: {e}"))?;
8226 run_shard_map_guard(topology.primary(), &computed, true).await
8227}
8228
8229#[cfg(feature = "db")]
8230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8231enum RepositoryCommitHookQueueMigrationMode {
8232 Runtime,
8233 StaticBuild,
8234}
8235
8236#[cfg(feature = "db")]
8237fn migrations_with_repository_framework_migrations(
8238 mut migrations: Vec<crate::migrate::EmbeddedMigrations>,
8239 hook_queue_required: bool,
8240 version_history_required: bool,
8241 mode: RepositoryCommitHookQueueMigrationMode,
8242) -> Vec<crate::migrate::EmbeddedMigrations> {
8243 if hook_queue_required
8244 && mode == RepositoryCommitHookQueueMigrationMode::Runtime
8245 && !shard_applied_sets_include(&migrations, REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION)
8246 {
8247 migrations.push(crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS);
8248 }
8249 if version_history_required
8250 && mode == RepositoryCommitHookQueueMigrationMode::Runtime
8251 && !shard_applied_sets_include(&migrations, VERSION_HISTORY_MIGRATION)
8252 {
8253 migrations.push(crate::version_history::VERSION_HISTORY_MIGRATIONS);
8254 }
8255 migrations
8256}
8257
8258#[cfg(feature = "db")]
8273fn shard_applied_sets_include(
8274 migrations: &[crate::migrate::EmbeddedMigrations],
8275 migration_name: &str,
8276) -> bool {
8277 use diesel::migration::{Migration, MigrationSource as _};
8278 use diesel::pg::Pg;
8279
8280 migrations
8281 .iter()
8282 .filter(|set| !migration_set_is_control_framework(set))
8283 .any(|source| {
8284 let Ok(source_migrations): Result<Vec<Box<dyn Migration<Pg>>>, _> = source.migrations()
8285 else {
8286 return false;
8287 };
8288
8289 source_migrations
8290 .iter()
8291 .any(|migration| migration.name().to_string() == migration_name)
8292 })
8293}
8294
8295#[cfg(feature = "db")]
8306fn migration_set_is_control_framework(set: &crate::migrate::EmbeddedMigrations) -> bool {
8307 use diesel::migration::{Migration, MigrationSource as _};
8308 use diesel::pg::Pg;
8309
8310 fn names(set: &crate::migrate::EmbeddedMigrations) -> std::collections::HashSet<String> {
8311 let migrations: Vec<Box<dyn Migration<Pg>>> = set.migrations().unwrap_or_default();
8312 migrations.iter().map(|m| m.name().to_string()).collect()
8313 }
8314
8315 let mut control_only = names(&crate::migrate::FRAMEWORK_MIGRATIONS);
8316 for shard_required in [
8317 &crate::version_history::VERSION_HISTORY_MIGRATIONS,
8318 &crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS,
8319 ] {
8320 for name in names(shard_required) {
8321 control_only.remove(&name);
8322 }
8323 }
8324
8325 names(set).iter().any(|name| control_only.contains(name))
8326}
8327
8328#[cfg(feature = "db")]
8329fn apply_replica_migration_readiness(
8330 state: &AppState,
8331 readiness: Option<crate::migrate::ReplicaMigrationReadiness>,
8332) {
8333 let Some(readiness) = readiness else {
8334 return;
8335 };
8336
8337 if readiness.is_ready() {
8338 state.probes().mark_replica_migrations_ready();
8339 } else if let Some(detail) = readiness.detail() {
8340 state.probes().mark_replica_migrations_unready(detail);
8341 }
8342}
8343
8344#[cfg(feature = "db")]
8345fn configure_replica_migration_check(state: &AppState, check: Option<(String, String)>) {
8346 let Some((primary_url, replica_url)) = check else {
8347 return;
8348 };
8349
8350 state
8351 .probes()
8352 .configure_replica_migration_check(primary_url, replica_url);
8353}
8354
8355fn collect_unguarded_repository_writes(
8378 routes: &[Route],
8379 scoped_groups: &[ScopedGroup],
8380) -> Vec<(String, String)> {
8381 let mut offenders: Vec<(String, String)> = Vec::new();
8382 let mut seen: std::collections::HashSet<(&'static str, &'static str)> =
8383 std::collections::HashSet::new();
8384 let mut record_route = |route: &Route| {
8385 if let Some(meta) = route.repository
8386 && !meta.has_policy
8387 && is_mutating_method(&route.method)
8388 && seen.insert((meta.resource_type_name, meta.api_path))
8389 {
8390 offenders.push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8391 }
8392 };
8393 for route in routes {
8394 record_route(route);
8395 }
8396 for group in scoped_groups {
8397 for route in &group.routes {
8398 record_route(route);
8399 }
8400 }
8401 offenders
8402}
8403
8404fn format_unguarded_repository_listing(offenders: &[(String, String)]) -> String {
8408 use std::fmt::Write;
8409 let mut s = String::new();
8410 let mut first = true;
8411 for (name, path) in offenders {
8412 if !first {
8413 s.push('\n');
8414 }
8415 first = false;
8416 write!(s, " - #[repository({name}, api = \"{path}\")]").unwrap();
8417 }
8418 s
8419}
8420
8421fn validate_repository_api_policies(
8422 routes: &[Route],
8423 scoped_groups: &[ScopedGroup],
8424 config: &AutumnConfig,
8425) {
8426 let profile = config.profile.as_deref().unwrap_or("default");
8427 let strict =
8428 is_production_profile(profile) && !config.security.allow_unauthorized_repository_api;
8429
8430 let offenders = collect_unguarded_repository_writes(routes, scoped_groups);
8431 if offenders.is_empty() {
8432 return;
8433 }
8434
8435 let listing = format_unguarded_repository_listing(&offenders);
8436
8437 if strict {
8438 tracing::error!(
8439 "refusing to start: the following #[repository(api = ...)] mutating endpoints have no paired `policy = ...` argument:\n{listing}\n\
8440 Add `policy = SomePolicy` to each, or set `[security] allow_unauthorized_repository_api = true` to opt out explicitly."
8441 );
8442 std::process::exit(1);
8443 } else {
8444 tracing::warn!(
8445 "the following #[repository(api = ...)] mutating endpoints have no paired `policy = ...` argument; \
8446 auto-generated POST/PUT/PATCH/DELETE handlers will accept writes from any authenticated user:\n{listing}\n\
8447 This will become a startup-time error in `prod` profile builds."
8448 );
8449 }
8450}
8451
8452type MissingRepositoryRegistration = (String, String);
8467
8468fn collect_unregistered_repository_handlers(
8478 routes: &[Route],
8479 scoped_groups: &[ScopedGroup],
8480 registry: &crate::authorization::PolicyRegistry,
8481) -> (
8482 Vec<MissingRepositoryRegistration>,
8483 Vec<MissingRepositoryRegistration>,
8484) {
8485 let mut missing_policies: Vec<(String, String)> = Vec::new();
8486 let mut missing_scopes: Vec<(String, String)> = Vec::new();
8487 let mut seen_policies: std::collections::HashSet<(&'static str, &'static str)> =
8488 std::collections::HashSet::new();
8489 let mut seen_scopes: std::collections::HashSet<(&'static str, &'static str)> =
8490 std::collections::HashSet::new();
8491 let mut record_route = |route: &Route| {
8492 if let Some(meta) = route.repository {
8493 if let Some(check) = meta.policy_check
8494 && !check(registry)
8495 && seen_policies.insert((meta.resource_type_name, meta.api_path))
8496 {
8497 missing_policies
8498 .push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8499 }
8500 if let Some(check) = meta.scope_check
8501 && !check(registry)
8502 && seen_scopes.insert((meta.resource_type_name, meta.api_path))
8503 {
8504 missing_scopes.push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8505 }
8506 }
8507 };
8508 for route in routes {
8509 record_route(route);
8510 }
8511 for group in scoped_groups {
8512 for route in &group.routes {
8513 record_route(route);
8514 }
8515 }
8516 (missing_policies, missing_scopes)
8517}
8518
8519fn format_missing_policy_listing(missing: &[(String, String)]) -> String {
8522 use std::fmt::Write;
8523 let mut s = String::new();
8524 let mut first = true;
8525 for (name, path) in missing {
8526 if !first {
8527 s.push('\n');
8528 }
8529 first = false;
8530 write!(s, " - #[repository({name}, api = \"{path}\", policy = ...)]: call `.policy::<{name}, _>(...)` on the app builder").unwrap();
8531 }
8532 s
8533}
8534
8535fn format_missing_scope_listing(missing: &[(String, String)]) -> String {
8538 use std::fmt::Write;
8539 let mut s = String::new();
8540 let mut first = true;
8541 for (name, path) in missing {
8542 if !first {
8543 s.push('\n');
8544 }
8545 first = false;
8546 write!(s, " - #[repository({name}, api = \"{path}\", scope = ...)]: call `.scope::<{name}, _>(...)` on the app builder").unwrap();
8547 }
8548 s
8549}
8550
8551#[allow(clippy::cognitive_complexity)]
8552fn validate_repository_policies_registered(
8553 routes: &[Route],
8554 scoped_groups: &[ScopedGroup],
8555 state: &AppState,
8556 config: &AutumnConfig,
8557) {
8558 let profile = config.profile.as_deref().unwrap_or("default");
8559 let strict = is_production_profile(profile);
8560
8561 let (missing_policies, missing_scopes) =
8562 collect_unregistered_repository_handlers(routes, scoped_groups, state.policy_registry());
8563
8564 if missing_policies.is_empty() && missing_scopes.is_empty() {
8565 return;
8566 }
8567
8568 if !missing_policies.is_empty() {
8569 let listing = format_missing_policy_listing(&missing_policies);
8570
8571 if strict {
8572 tracing::error!(
8573 "refusing to start: the following #[repository] routes declare a `policy = ...` argument, but no policy is registered for the resource type. Without registration, every protected request would fail at runtime with `500 no policy registered`:\n{listing}"
8574 );
8575 } else {
8576 tracing::warn!(
8577 "the following #[repository] routes declare `policy = ...` but no matching `.policy::<R, _>(...)` registration is on the app builder. Protected requests will 500 at runtime:\n{listing}\n\
8578 This will become a startup-time error in `prod` profile builds."
8579 );
8580 }
8581 }
8582
8583 if !missing_scopes.is_empty() {
8584 let listing = format_missing_scope_listing(&missing_scopes);
8585
8586 if strict {
8587 tracing::error!(
8588 "refusing to start: the following #[repository] routes declare a `scope = ...` argument, but no scope is registered for the resource type. Without registration, every list request would fail at runtime with `500 missing scope registration`:\n{listing}"
8589 );
8590 } else {
8591 tracing::warn!(
8592 "the following #[repository] routes declare `scope = ...` but no matching `.scope::<R, _>(...)` registration is on the app builder. List requests will 500 at runtime:\n{listing}\n\
8593 This will become a startup-time error in `prod` profile builds."
8594 );
8595 }
8596 }
8597
8598 if strict {
8599 std::process::exit(1);
8600 }
8601}
8602
8603const fn is_mutating_method(method: &http::Method) -> bool {
8604 matches!(
8605 *method,
8606 http::Method::POST | http::Method::PUT | http::Method::PATCH | http::Method::DELETE
8607 )
8608}
8609
8610fn is_production_profile(profile: &str) -> bool {
8616 matches!(profile, "prod" | "production")
8617}
8618
8619#[cfg(test)]
8620mod validate_repository_api_policies_tests {
8621 use super::*;
8622 use crate::RepositoryApiMeta;
8623
8624 fn build_route(
8625 method: http::Method,
8626 path: &'static str,
8627 meta: Option<RepositoryApiMeta>,
8628 ) -> Route {
8629 Route {
8630 method,
8631 path,
8632 handler: axum::routing::any(|| async { "" }),
8633 name: "test_route",
8634 api_doc: crate::openapi::ApiDoc::default(),
8635 repository: meta,
8636 idempotency: crate::route::RouteIdempotency::Direct,
8637 timeout: crate::route::RouteTimeout::Inherit,
8638 api_version: None,
8639 sunset_opt_out: false,
8640 }
8641 }
8642
8643 fn unguarded(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8644 RepositoryApiMeta {
8645 resource_type_name: type_name,
8646 api_path: path,
8647 has_policy: false,
8648 policy_check: None,
8649 scope_check: None,
8650 }
8651 }
8652
8653 fn collect_offenders(routes: &[Route]) -> Vec<(String, String)> {
8657 collect_unguarded_repository_writes(routes, &[])
8658 }
8659
8660 #[test]
8661 fn read_only_mount_without_policy_is_not_an_offender() {
8662 let routes = vec![
8663 build_route(
8664 http::Method::GET,
8665 "/api/posts",
8666 Some(unguarded("/api/posts", "Post")),
8667 ),
8668 build_route(
8669 http::Method::GET,
8670 "/api/posts/{id}",
8671 Some(unguarded("/api/posts", "Post")),
8672 ),
8673 ];
8674 let offenders = collect_offenders(&routes);
8675 assert!(
8676 offenders.is_empty(),
8677 "read-only mounts should not trigger the unauthorized-repo guard"
8678 );
8679 }
8680
8681 #[test]
8682 fn write_mount_without_policy_is_an_offender() {
8683 let routes = vec![build_route(
8684 http::Method::POST,
8685 "/api/posts",
8686 Some(unguarded("/api/posts", "Post")),
8687 )];
8688 let offenders = collect_offenders(&routes);
8689 assert_eq!(offenders.len(), 1);
8690 assert_eq!(offenders[0].0, "Post");
8691 assert_eq!(offenders[0].1, "/api/posts");
8692 }
8693
8694 #[test]
8695 fn mixed_mount_only_dedups_one_offender_per_repository() {
8696 let routes = vec![
8697 build_route(
8698 http::Method::GET,
8699 "/api/posts",
8700 Some(unguarded("/api/posts", "Post")),
8701 ),
8702 build_route(
8703 http::Method::POST,
8704 "/api/posts",
8705 Some(unguarded("/api/posts", "Post")),
8706 ),
8707 build_route(
8708 http::Method::PUT,
8709 "/api/posts/{id}",
8710 Some(unguarded("/api/posts", "Post")),
8711 ),
8712 build_route(
8713 http::Method::DELETE,
8714 "/api/posts/{id}",
8715 Some(unguarded("/api/posts", "Post")),
8716 ),
8717 ];
8718 let offenders = collect_offenders(&routes);
8719 assert_eq!(offenders.len(), 1);
8720 }
8721
8722 #[test]
8723 fn is_mutating_method_classifies_methods() {
8724 assert!(is_mutating_method(&http::Method::POST));
8725 assert!(is_mutating_method(&http::Method::PUT));
8726 assert!(is_mutating_method(&http::Method::PATCH));
8727 assert!(is_mutating_method(&http::Method::DELETE));
8728 assert!(!is_mutating_method(&http::Method::GET));
8729 assert!(!is_mutating_method(&http::Method::HEAD));
8730 assert!(!is_mutating_method(&http::Method::OPTIONS));
8731 }
8732
8733 use crate::authorization::{Policy, PolicyRegistry};
8736
8737 #[derive(Debug, Clone, PartialEq)]
8738 struct TestPost;
8739
8740 #[derive(Default)]
8741 struct TestPostPolicy;
8742 impl Policy<TestPost> for TestPostPolicy {}
8743
8744 fn guarded_with_check(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8745 RepositoryApiMeta {
8746 resource_type_name: type_name,
8747 api_path: path,
8748 has_policy: true,
8749 policy_check: Some(|registry: &PolicyRegistry| registry.has_policy::<TestPost>()),
8750 scope_check: None,
8751 }
8752 }
8753
8754 fn collect_missing(routes: &[Route], registry: &PolicyRegistry) -> Vec<(String, String)> {
8755 let (missing_policies, _) = collect_unregistered_repository_handlers(routes, &[], registry);
8756 missing_policies
8757 }
8758
8759 #[test]
8760 fn registry_check_flags_routes_missing_their_policy_registration() {
8761 let registry = PolicyRegistry::default();
8764 let routes = vec![build_route(
8765 http::Method::POST,
8766 "/api/posts",
8767 Some(guarded_with_check("/api/posts", "TestPost")),
8768 )];
8769 let missing = collect_missing(&routes, ®istry);
8770 assert_eq!(missing.len(), 1);
8771 assert_eq!(missing[0].0, "TestPost");
8772 assert_eq!(missing[0].1, "/api/posts");
8773 }
8774
8775 #[test]
8776 fn registry_check_passes_when_policy_is_registered() {
8777 let registry = PolicyRegistry::default();
8778 registry.register_policy::<TestPost, _>(TestPostPolicy);
8779 let routes = vec![build_route(
8780 http::Method::POST,
8781 "/api/posts",
8782 Some(guarded_with_check("/api/posts", "TestPost")),
8783 )];
8784 let missing = collect_missing(&routes, ®istry);
8785 assert!(missing.is_empty(), "policy is registered, no offenders");
8786 }
8787
8788 #[test]
8789 fn registry_check_skips_routes_without_policy_check_fn() {
8790 let registry = PolicyRegistry::default();
8795 let routes = vec![build_route(
8796 http::Method::POST,
8797 "/api/posts",
8798 Some(unguarded("/api/posts", "TestPost")),
8799 )];
8800 let missing = collect_missing(&routes, ®istry);
8801 assert!(missing.is_empty());
8802 }
8803
8804 #[test]
8805 fn registry_check_dedups_one_offender_per_repository() {
8806 let registry = PolicyRegistry::default();
8807 let routes = vec![
8808 build_route(
8809 http::Method::GET,
8810 "/api/posts",
8811 Some(guarded_with_check("/api/posts", "TestPost")),
8812 ),
8813 build_route(
8814 http::Method::POST,
8815 "/api/posts",
8816 Some(guarded_with_check("/api/posts", "TestPost")),
8817 ),
8818 build_route(
8819 http::Method::DELETE,
8820 "/api/posts/{id}",
8821 Some(guarded_with_check("/api/posts", "TestPost")),
8822 ),
8823 ];
8824 let missing = collect_missing(&routes, ®istry);
8825 assert_eq!(missing.len(), 1);
8826 }
8827
8828 use crate::authorization::{BoxFuture, PolicyContext, Scope};
8831
8832 #[derive(Default)]
8833 struct TestPostScope;
8834 impl Scope<TestPost> for TestPostScope {
8835 fn list<'a>(
8836 &'a self,
8837 _ctx: &'a PolicyContext,
8838 _conn: &'a mut crate::db::RuntimeConnection,
8839 ) -> BoxFuture<'a, crate::AutumnResult<Vec<TestPost>>> {
8840 Box::pin(async { Ok(Vec::new()) })
8841 }
8842 }
8843
8844 fn scope_only_meta(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8845 RepositoryApiMeta {
8846 resource_type_name: type_name,
8847 api_path: path,
8848 has_policy: false,
8849 policy_check: None,
8850 scope_check: Some(|registry: &PolicyRegistry| registry.scope::<TestPost>().is_some()),
8851 }
8852 }
8853
8854 fn collect_missing_scopes(
8855 routes: &[Route],
8856 registry: &PolicyRegistry,
8857 ) -> Vec<(String, String)> {
8858 let (_, missing_scopes) = collect_unregistered_repository_handlers(routes, &[], registry);
8859 missing_scopes
8860 }
8861
8862 #[test]
8863 fn scope_check_flags_unregistered_scope() {
8864 let registry = PolicyRegistry::default();
8865 let routes = vec![build_route(
8866 http::Method::GET,
8867 "/api/posts",
8868 Some(scope_only_meta("/api/posts", "TestPost")),
8869 )];
8870 let missing = collect_missing_scopes(&routes, ®istry);
8871 assert_eq!(missing.len(), 1);
8872 assert_eq!(missing[0].0, "TestPost");
8873 }
8874
8875 #[test]
8876 fn scope_check_passes_when_scope_is_registered() {
8877 let registry = PolicyRegistry::default();
8878 registry.register_scope::<TestPost, _>(TestPostScope);
8879 let routes = vec![build_route(
8880 http::Method::GET,
8881 "/api/posts",
8882 Some(scope_only_meta("/api/posts", "TestPost")),
8883 )];
8884 let missing = collect_missing_scopes(&routes, ®istry);
8885 assert!(missing.is_empty());
8886 }
8887
8888 #[test]
8889 fn scope_check_skips_routes_without_scope_check_fn() {
8890 let registry = PolicyRegistry::default();
8891 let routes = vec![build_route(
8892 http::Method::POST,
8893 "/api/posts",
8894 Some(unguarded("/api/posts", "TestPost")),
8895 )];
8896 let missing = collect_missing_scopes(&routes, ®istry);
8897 assert!(missing.is_empty());
8898 }
8899
8900 #[test]
8903 fn is_production_profile_matches_both_aliases() {
8904 assert!(is_production_profile("prod"));
8905 assert!(is_production_profile("production"));
8906 assert!(!is_production_profile("dev"));
8907 assert!(!is_production_profile("staging"));
8908 assert!(!is_production_profile("test"));
8909 assert!(!is_production_profile("default"));
8910 assert!(!is_production_profile("Prod"));
8914 assert!(!is_production_profile("Production"));
8915 }
8916
8917 #[test]
8920 fn format_unguarded_listing_renders_one_bullet_per_offender() {
8921 let offenders = vec![
8922 ("Post".to_owned(), "/api/posts".to_owned()),
8923 ("Comment".to_owned(), "/api/comments".to_owned()),
8924 ];
8925 let listing = format_unguarded_repository_listing(&offenders);
8926 assert!(listing.contains("Post"));
8927 assert!(listing.contains("/api/posts"));
8928 assert!(listing.contains("Comment"));
8929 assert!(listing.contains("/api/comments"));
8930 assert_eq!(listing.matches("\n - ").count() + 1, 2);
8931 }
8932
8933 #[test]
8934 fn format_unguarded_listing_empty_input_yields_empty_string() {
8935 let listing = format_unguarded_repository_listing(&[]);
8936 assert!(listing.is_empty());
8937 }
8938
8939 #[test]
8940 fn format_missing_policy_listing_includes_policy_call_hint() {
8941 let missing = vec![("Post".to_owned(), "/api/posts".to_owned())];
8942 let listing = format_missing_policy_listing(&missing);
8943 assert!(listing.contains("Post"));
8944 assert!(listing.contains("/api/posts"));
8945 assert!(listing.contains(".policy::<Post, _>"));
8946 assert!(listing.contains("policy = ..."));
8947 }
8948
8949 #[test]
8950 fn format_missing_scope_listing_includes_scope_call_hint() {
8951 let missing = vec![("Post".to_owned(), "/api/posts".to_owned())];
8952 let listing = format_missing_scope_listing(&missing);
8953 assert!(listing.contains("Post"));
8954 assert!(listing.contains("/api/posts"));
8955 assert!(listing.contains(".scope::<Post, _>"));
8956 assert!(listing.contains("scope = ..."));
8957 }
8958
8959 #[test]
8962 fn collect_unguarded_walks_scoped_groups() {
8963 let group_route = build_route(
8968 http::Method::POST,
8969 "/api/posts",
8970 Some(unguarded("/api/posts", "Post")),
8971 );
8972 let group = ScopedGroup {
8973 prefix: "/scoped".to_owned(),
8974 routes: vec![group_route],
8975 source: crate::route_listing::RouteSource::User,
8976 apply_layer: Box::new(|r| r),
8977 };
8978 let offenders = collect_unguarded_repository_writes(&[], std::slice::from_ref(&group));
8979 assert_eq!(offenders.len(), 1);
8980 assert_eq!(offenders[0].0, "Post");
8981 }
8982
8983 #[test]
8984 fn collect_unregistered_walks_scoped_groups() {
8985 let group_route = build_route(
8986 http::Method::POST,
8987 "/api/posts",
8988 Some(guarded_with_check("/api/posts", "TestPost")),
8989 );
8990 let group = ScopedGroup {
8991 prefix: "/scoped".to_owned(),
8992 routes: vec![group_route],
8993 source: crate::route_listing::RouteSource::User,
8994 apply_layer: Box::new(|r| r),
8995 };
8996 let registry = PolicyRegistry::default();
8997 let (missing, _) =
8998 collect_unregistered_repository_handlers(&[], std::slice::from_ref(&group), ®istry);
8999 assert_eq!(missing.len(), 1);
9000 assert_eq!(missing[0].0, "TestPost");
9001 }
9002}
9003
9004#[cfg(feature = "maud")]
9009fn install_story_registry(state: &AppState, story_gallery: Option<crate::stories::StoryGallery>) {
9010 if let Some(gallery) = story_gallery {
9011 state.insert_extension(gallery.into_registry());
9012 }
9013}
9014
9015fn build_state(
9016 config: &AutumnConfig,
9017 #[cfg(feature = "db")] database_topology: Option<&crate::db::DatabaseTopology>,
9018 #[cfg(feature = "db")] shards: Option<crate::sharding::ShardSet>,
9019 #[cfg(feature = "ws")] channels_backend: Option<Arc<dyn crate::channels::ChannelsBackend>>,
9020) -> AppState {
9021 #[cfg(feature = "ws")]
9022 let shutdown = tokio_util::sync::CancellationToken::new();
9023 #[cfg(feature = "ws")]
9024 let channels = channels_backend.map_or_else(
9025 || {
9026 crate::channels::Channels::from_config(&config.channels, shutdown.child_token())
9027 .unwrap_or_else(|error| {
9028 tracing::error!(error = %error, "Failed to configure channels backend");
9029 std::process::exit(1);
9030 })
9031 },
9032 crate::channels::Channels::with_shared_backend,
9033 );
9034
9035 let state = AppState {
9036 extensions: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
9037 #[cfg(feature = "db")]
9038 pool: database_topology.map(|topology| topology.primary().clone()),
9039 #[cfg(feature = "db")]
9040 replica_pool: database_topology.and_then(|topology| topology.replica().cloned()),
9041 #[cfg(feature = "db")]
9042 shards,
9043 profile: config.profile.clone(),
9044 role: config.role,
9045 started_at: std::time::Instant::now(),
9046 health_detailed: config.health.detailed,
9047 probes: crate::probe::ProbeState::pending_startup(),
9048 metrics: crate::middleware::MetricsCollector::new(),
9049 log_levels: crate::actuator::LogLevels::new(&config.log.level),
9050 task_registry: crate::actuator::TaskRegistry::new(),
9051 job_registry: crate::actuator::JobRegistry::new(),
9052 config_props: crate::actuator::ConfigProperties::from_config(config),
9053 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
9054 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
9055 #[cfg(feature = "presence")]
9056 presence: crate::presence::Presence::new(channels.clone()),
9057 #[cfg(feature = "ws")]
9058 channels,
9059 #[cfg(feature = "ws")]
9060 shutdown,
9061 policy_registry: crate::authorization::PolicyRegistry::default(),
9062 forbidden_response: config.security.forbidden_response,
9063 auth_session_key: config.auth.session_key.clone(),
9064 shared_cache: None,
9065 clock: std::sync::Arc::new(crate::time::SystemClock),
9066 app_id: AppState::next_app_id(),
9067 };
9068 #[cfg(feature = "db")]
9069 if state.replica_pool.is_some() {
9070 state
9071 .probes()
9072 .configure_replica_dependency(config.database.replica_fallback);
9073 }
9074 #[cfg(feature = "db")]
9077 if let Some(set) = state.shards() {
9078 crate::sharding::register_shard_health_indicators(set, &state.health_indicator_registry);
9079 }
9080 state.insert_extension(config.clone());
9081 state.insert_extension(crate::step_up::StepUpGlobalConfig {
9082 default_max_age_secs: config.auth.step_up.default_max_age_secs,
9083 });
9084 #[cfg(feature = "http-client")]
9085 state.insert_extension(crate::http_client::SharedReqwestClient {
9086 client: crate::http_client::Client::build_inner(&config.http.client),
9087 timeout_secs: config.http.client.timeout_secs,
9088 });
9089 state
9090}
9091
9092fn format_route_lines(
9094 routes: &[Route],
9095 scoped_groups: &[ScopedGroup],
9096 config: &AutumnConfig,
9097) -> String {
9098 use std::fmt::Write as _;
9099
9100 let mut out = String::new();
9101 for route in routes {
9102 let _ = write!(
9103 out,
9104 "\n {} {:<8} -> {}",
9105 route.path, route.method, route.name
9106 );
9107 }
9108 for group in scoped_groups {
9109 for route in &group.routes {
9110 let _ = write!(
9111 out,
9112 "\n {}{} {:<8} -> {} (scoped)",
9113 group.prefix, route.path, route.method, route.name
9114 );
9115 }
9116 }
9117 let mut probe_paths = std::collections::HashSet::new();
9118 for (path, name) in [
9119 (config.health.live_path.as_str(), "live"),
9120 (config.health.ready_path.as_str(), "ready"),
9121 (config.health.startup_path.as_str(), "startup"),
9122 (config.health.path.as_str(), "health"),
9123 ] {
9124 if probe_paths.insert(path) {
9125 let _ = write!(out, "\n {} {:<8} -> {}", path, "GET", name);
9126 }
9127 }
9128 let _ = write!(
9129 out,
9130 "\n {} {:<8} -> actuator",
9131 crate::actuator::actuator_route_glob(&config.actuator.prefix),
9132 "GET"
9133 );
9134 #[cfg(feature = "htmx")]
9135 {
9136 out.push_str("\n /static/js/htmx.min.js GET -> htmx");
9137 out.push_str("\n /static/js/autumn-htmx-csrf.js GET -> htmx csrf");
9138 }
9139 out
9140}
9141
9142fn format_task_lines(tasks: &[crate::task::TaskInfo]) -> Option<String> {
9144 use std::fmt::Write as _;
9145
9146 if tasks.is_empty() {
9147 return None;
9148 }
9149
9150 let mut out = String::new();
9151 for task in tasks {
9152 let schedule = task.schedule.to_string();
9153 let _ = write!(out, "\n {} ({schedule})", task.name);
9154 }
9155 Some(out)
9156}
9157
9158fn format_middleware_list(config: &AutumnConfig) -> String {
9160 let mut items = vec![
9161 "RequestId",
9162 "SecurityHeaders",
9163 "Session (in-memory)",
9164 "ErrorPages",
9165 ];
9166 if !config.cors.allowed_origins.is_empty() {
9167 items.push("CORS");
9168 }
9169 if config.security.csrf.enabled {
9170 items.push("CSRF");
9171 }
9172 items.push("Metrics");
9173 items.join(", ")
9174}
9175
9176fn mask_database_url(url: &str, pool_size: usize) -> String {
9178 if let Ok(mut parsed_url) = url::Url::parse(url) {
9179 if parsed_url.password().is_some() {
9180 let _ = parsed_url.set_password(Some("****"));
9181 return format!("{parsed_url} (pool_size={pool_size})");
9182 }
9183 format!("{parsed_url} (pool_size={pool_size})")
9184 } else {
9185 format!("**** (pool_size={pool_size})")
9188 }
9189}
9190
9191fn format_config_summary(config: &AutumnConfig) -> String {
9193 let profile = config.profile.as_deref().unwrap_or("none");
9194 let db_status = config.database.effective_primary_url().map_or_else(
9195 || "not configured".to_owned(),
9196 |url| {
9197 let primary = mask_database_url(url, config.database.effective_primary_pool_size());
9198 if config.database.replica_url.is_some() {
9199 format!(
9200 "primary={primary}, replica=configured (pool_size={})",
9201 config.database.effective_replica_pool_size()
9202 )
9203 } else {
9204 primary
9205 }
9206 },
9207 );
9208 let telemetry_status = if config.telemetry.enabled {
9209 let endpoint = config
9210 .telemetry
9211 .otlp_endpoint
9212 .as_deref()
9213 .unwrap_or("<missing endpoint>");
9214 format!("{:?} -> {endpoint}", config.telemetry.protocol)
9215 } else {
9216 "disabled".to_owned()
9217 };
9218 format!(
9219 "\
9220 \n profile: {profile}\
9221 \n server: {}:{}\
9222 \n database: {db_status}\
9223 \n log_level: {}\
9224 \n log_format: {:?}\
9225 \n telemetry: {telemetry_status}\
9226 \n health: {} (detailed={})\
9227 \n actuator: sensitive={}\
9228 \n shutdown: prestop={}s drain={}s",
9229 config.server.host,
9230 config.server.port,
9231 config.log.level,
9232 config.log.format,
9233 config.health.path,
9234 config.health.detailed,
9235 config.actuator.sensitive,
9236 config.server.prestop_grace_secs,
9237 config.server.shutdown_timeout_secs,
9238 )
9239}
9240
9241pub(crate) fn project_dir(subdir: &str, env: &dyn crate::config::Env) -> std::path::PathBuf {
9244 env.var("AUTUMN_MANIFEST_DIR").map_or_else(
9245 |_| std::path::PathBuf::from(subdir),
9246 |d| std::path::PathBuf::from(d).join(subdir),
9247 )
9248}
9249
9250async fn shutdown_signal() {
9261 let ctrl_c = async {
9262 tokio::signal::ctrl_c()
9263 .await
9264 .expect("Failed to install Ctrl+C handler");
9265 tracing::info!("Received Ctrl+C, starting graceful shutdown");
9266 };
9267
9268 #[cfg(unix)]
9269 let terminate = async {
9270 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
9271 .expect("Failed to install SIGTERM handler")
9272 .recv()
9273 .await;
9274 tracing::info!("Received SIGTERM, starting graceful shutdown");
9275 };
9276
9277 #[cfg(not(unix))]
9278 let terminate = std::future::pending::<()>();
9279
9280 let canary_rollback = async {
9281 canary_rollback_signal(std::path::Path::new(
9282 crate::canary::CANARY_ROLLBACK_FLAG_FILE,
9283 ))
9284 .await;
9285 tracing::info!("Canary rollback signalled, starting graceful shutdown");
9286 };
9287
9288 tokio::select! {
9289 () = ctrl_c => {},
9290 () = terminate => {},
9291 () = canary_rollback => {},
9292 }
9293}
9294
9295async fn canary_rollback_signal(path: &std::path::Path) {
9308 let interval = std::time::Duration::from_millis(500);
9309 loop {
9310 if tokio::fs::metadata(path).await.is_ok() {
9311 return;
9312 }
9313 tokio::time::sleep(interval).await;
9314 }
9315}
9316
9317#[cfg(test)]
9318mod tests {
9319 use super::*;
9320 use axum::body::Body;
9321 use axum::http::{Request, StatusCode};
9322 use std::sync::atomic::{AtomicUsize, Ordering};
9323 use tower::ServiceExt;
9324
9325 #[test]
9332 fn plugin_declares_config_section_via_build() {
9333 struct DummyMediaPlugin;
9334 impl crate::plugin::Plugin for DummyMediaPlugin {
9335 fn build(self, app: AppBuilder) -> AppBuilder {
9336 app.config_section("media")
9337 }
9338 }
9339
9340 let builder = app().plugin(DummyMediaPlugin);
9341 assert!(
9342 builder.has_config_section("media"),
9343 "a plugin's build() must declare its [media] config section"
9344 );
9345 assert!(
9346 !builder.has_config_section("definitely_not_a_root"),
9347 "only explicitly-declared roots are registered — the seam is fail-closed"
9348 );
9349 }
9350
9351 fn omitted_for(builder: &AppBuilder) -> usize {
9355 omitted_router_count(
9356 builder.merge_routers.len(),
9357 builder
9358 .nest_routers
9359 .iter()
9360 .map(|(prefix, _)| prefix.as_str()),
9361 &builder.declared_routes,
9362 )
9363 }
9364
9365 #[test]
9375 fn documented_nest_then_declare_is_not_counted_as_omitted() {
9376 let raw =
9377 axum::Router::<AppState>::new().route("/ping", axum::routing::get(|| async { "pong" }));
9378 let declared = vec![crate::route_listing::RouteInfo {
9379 method: "GET".to_owned(),
9380 path: "/admin/ping".to_owned(),
9381 handler: "admin::ping".to_owned(),
9382 ..Default::default()
9383 }];
9384
9385 let builder = app().nest("/admin", raw).declare_plugin_routes(declared);
9388
9389 assert_eq!(builder.nest_routers.len(), 1);
9391 assert_eq!(builder.declared_routes.len(), 1);
9393
9394 assert_eq!(
9398 omitted_for(&builder),
9399 0,
9400 "a nest whose endpoints are declared is enumerable and must not count as omitted",
9401 );
9402 }
9403
9404 #[test]
9408 fn undeclared_nest_and_merge_still_count_as_omitted() {
9409 let raw_nest =
9410 axum::Router::<AppState>::new().route("/x", axum::routing::get(|| async { "x" }));
9411 let raw_merge =
9412 axum::Router::<AppState>::new().route("/y", axum::routing::get(|| async { "y" }));
9413
9414 let builder = app().nest("/v2", raw_nest).merge(raw_merge);
9415
9416 assert_eq!(builder.nest_routers.len(), 1);
9417 assert_eq!(builder.merge_routers.len(), 1);
9418 assert!(builder.declared_routes.is_empty());
9420
9421 assert_eq!(
9422 omitted_for(&builder),
9423 2,
9424 "an undeclared nest and a merge are both opaque and must be reported",
9425 );
9426 }
9427
9428 #[test]
9432 fn declared_routes_do_not_cover_a_rootless_merge() {
9433 let raw_merge =
9434 axum::Router::<AppState>::new().route("/y", axum::routing::get(|| async { "y" }));
9435
9436 let builder =
9437 app()
9438 .merge(raw_merge)
9439 .declare_plugin_routes(vec![crate::route_listing::RouteInfo {
9440 method: "GET".to_owned(),
9441 path: "/admin/ok".to_owned(),
9442 handler: "admin::ok".to_owned(),
9443 ..Default::default()
9444 }]);
9445
9446 assert_eq!(
9447 omitted_for(&builder),
9448 1,
9449 "a merge has no prefix to match declarations against and must always count",
9450 );
9451 }
9452
9453 #[test]
9458 fn mixed_declared_and_undeclared_nests_count_only_the_undeclared() {
9459 let declared_raw =
9460 axum::Router::<AppState>::new().route("/ok", axum::routing::get(|| async { "ok" }));
9461 let undeclared_raw = axum::Router::<AppState>::new()
9462 .route("/opaque", axum::routing::get(|| async { "opaque" }));
9463
9464 let builder = app()
9465 .nest("/admin", declared_raw)
9466 .declare_plugin_routes(vec![crate::route_listing::RouteInfo {
9467 method: "GET".to_owned(),
9468 path: "/admin/ok".to_owned(),
9469 handler: "admin::ok".to_owned(),
9470 ..Default::default()
9471 }])
9472 .nest("/raw", undeclared_raw);
9473
9474 assert_eq!(builder.nest_routers.len(), 2);
9475 assert_eq!(builder.declared_routes.len(), 1);
9476 assert_eq!(
9477 omitted_for(&builder),
9478 1,
9479 "only the bare nest() is omitted; the declared mount is covered",
9480 );
9481 }
9482
9483 #[test]
9488 fn prefix_match_respects_path_segment_boundaries() {
9489 let raw = axum::Router::<AppState>::new().route("/x", axum::routing::get(|| async { "x" }));
9490
9491 let builder = app().nest("/admin", raw).declare_plugin_routes(vec![
9492 crate::route_listing::RouteInfo {
9493 method: "GET".to_owned(),
9494 path: "/administrators".to_owned(),
9495 handler: "other::index".to_owned(),
9496 ..Default::default()
9497 },
9498 ]);
9499
9500 assert_eq!(
9501 omitted_for(&builder),
9502 1,
9503 "`/administrators` is not under the `/admin` nest prefix; the nest stays omitted",
9504 );
9505 }
9506
9507 #[test]
9508 fn is_dump_jobs_mode_only_true_for_exactly_one() {
9509 temp_env::with_var("AUTUMN_DUMP_JOBS", Some("1"), || {
9513 assert!(is_dump_jobs_mode(), "`1` must select the jobs-dump path");
9514 });
9515 temp_env::with_var("AUTUMN_DUMP_JOBS", Some("0"), || {
9516 assert!(!is_dump_jobs_mode(), "`0` must not select the dump path");
9517 });
9518 temp_env::with_var("AUTUMN_DUMP_JOBS", Some("true"), || {
9519 assert!(
9520 !is_dump_jobs_mode(),
9521 "only the literal `1` enables the mode"
9522 );
9523 });
9524 temp_env::with_var("AUTUMN_DUMP_JOBS", None::<&str>, || {
9525 assert!(!is_dump_jobs_mode(), "unset must not select the dump path");
9526 });
9527 }
9528
9529 #[test]
9530 fn dump_jobs_manifest_includes_synthesized_durable_listener_default_queue() {
9531 fn listener_handler(
9539 _state: AppState,
9540 _payload: serde_json::Value,
9541 ) -> std::pin::Pin<
9542 Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'static>,
9543 > {
9544 Box::pin(async move { Ok(()) })
9545 }
9546 let durable = crate::events::ListenerInfo {
9547 event_name: "UserSignedUp",
9548 listener_name: "app::send_welcome_email".to_string(),
9549 mode: crate::events::DispatchMode::Durable,
9550 job_name: Some("__event_listener::send_welcome_email".to_string()),
9551 max_attempts: 4,
9552 initial_backoff_ms: 250,
9553 handler: listener_handler,
9554 };
9555 let cfg = crate::config::JobQueuesConfig::strict_list(["critical"]);
9556
9557 let manifest = dump_jobs_manifest(&cfg, Vec::new(), vec![durable]);
9561 assert_eq!(manifest, "queues = [\"critical\", \"default\"]\n");
9562 }
9563
9564 #[cfg(feature = "db")]
9565 const APP_TEST_MIGRATIONS: crate::migrate::EmbeddedMigrations =
9566 diesel_migrations::embed_migrations!("test_migrations");
9567
9568 #[cfg(feature = "mail")]
9571 struct MailTestNoopQueue;
9572
9573 #[cfg(feature = "mail")]
9574 impl crate::mail::MailDeliveryQueue for MailTestNoopQueue {
9575 fn enqueue<'a>(
9576 &'a self,
9577 _mail: crate::mail::Mail,
9578 ) -> std::pin::Pin<
9579 Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
9580 > {
9581 Box::pin(async { Ok(()) })
9582 }
9583 }
9584
9585 #[cfg(feature = "mail")]
9586 fn test_mail() -> crate::mail::Mail {
9587 crate::mail::Mail::builder()
9588 .to("test@example.com")
9589 .subject("hi")
9590 .text("hello")
9591 .build()
9592 .expect("test mail should build")
9593 }
9594
9595 pub fn test_router(routes: Vec<Route>) -> axum::Router {
9597 let config = AutumnConfig::default();
9598 let state = AppState {
9599 extensions: std::sync::Arc::new(std::sync::RwLock::new(
9600 std::collections::HashMap::new(),
9601 )),
9602 #[cfg(feature = "db")]
9603 pool: None,
9604 #[cfg(feature = "db")]
9605 replica_pool: None,
9606 #[cfg(feature = "db")]
9607 shards: None,
9608 profile: None,
9609 role: crate::config::ProcessRole::Combined,
9610 started_at: std::time::Instant::now(),
9611 health_detailed: true,
9612 probes: crate::probe::ProbeState::ready_for_test(),
9613 metrics: crate::middleware::MetricsCollector::new(),
9614 log_levels: crate::actuator::LogLevels::new("info"),
9615 task_registry: crate::actuator::TaskRegistry::new(),
9616 job_registry: crate::actuator::JobRegistry::new(),
9617 config_props: crate::actuator::ConfigProperties::default(),
9618 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
9619 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
9620 #[cfg(feature = "ws")]
9621 channels: crate::channels::Channels::new(32),
9622 #[cfg(feature = "presence")]
9623 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
9624 #[cfg(feature = "ws")]
9625 shutdown: tokio_util::sync::CancellationToken::new(),
9626 policy_registry: crate::authorization::PolicyRegistry::default(),
9627 forbidden_response: crate::authorization::ForbiddenResponse::default(),
9628 auth_session_key: "user_id".to_owned(),
9629 shared_cache: None,
9630 clock: std::sync::Arc::new(crate::time::SystemClock),
9631 app_id: AppState::next_app_id(),
9632 };
9633 crate::router::build_router(routes, &config, state)
9634 }
9635
9636 #[tokio::test]
9637 async fn canary_rollback_signal_resolves_when_flag_newly_written() {
9638 let tmp = tempfile::TempDir::new().unwrap();
9639 let path = tmp.path().join("canary-rollback.json");
9640
9641 let writer_path = path.clone();
9643 let writer = tokio::spawn(async move {
9644 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
9645 crate::canary::CanaryState::write_rollback_flag(
9646 &writer_path,
9647 &crate::canary::RollbackSignal::default(),
9648 )
9649 .unwrap();
9650 });
9651
9652 let signalled = tokio::time::timeout(
9653 std::time::Duration::from_secs(5),
9654 canary_rollback_signal(&path),
9655 )
9656 .await;
9657 assert!(signalled.is_ok(), "rollback signal should resolve");
9658 writer.await.unwrap();
9659 }
9660
9661 #[tokio::test]
9662 async fn canary_rollback_signal_resolves_immediately_when_flag_present_at_boot() {
9663 let tmp = tempfile::TempDir::new().unwrap();
9664 let path = tmp.path().join("canary-rollback.json");
9665 crate::canary::CanaryState::write_rollback_flag(
9668 &path,
9669 &crate::canary::RollbackSignal::default(),
9670 )
9671 .unwrap();
9672
9673 let signalled = tokio::time::timeout(
9674 std::time::Duration::from_secs(5),
9675 canary_rollback_signal(&path),
9676 )
9677 .await;
9678 assert!(
9679 signalled.is_ok(),
9680 "a flag present at boot must trigger rollback (sticky across restarts)"
9681 );
9682 }
9683
9684 #[cfg(feature = "db")]
9685 #[test]
9686 fn build_state_applies_replica_fallback_policy_to_read_routing() {
9687 let mut config = AutumnConfig::default();
9688 config.database.primary_url = Some("postgres://localhost/primary".to_owned());
9689 config.database.primary_pool_size = Some(5);
9690 config.database.replica_url = Some("postgres://localhost/replica".to_owned());
9691 config.database.replica_pool_size = Some(2);
9692 config.database.replica_fallback = crate::config::ReplicaFallback::Primary;
9693 let topology = crate::db::create_topology(&config.database)
9694 .expect("topology should build")
9695 .expect("database should be configured");
9696
9697 let state = build_state(
9698 &config,
9699 Some(&topology),
9700 None,
9701 #[cfg(feature = "ws")]
9702 None,
9703 );
9704 state
9705 .probes()
9706 .mark_replica_unready("replica migrations lag primary");
9707
9708 assert_eq!(state.read_pool().expect("read pool").status().max_size, 5);
9709 }
9710
9711 #[test]
9712 fn build_state_exposes_resolved_process_role() {
9713 use crate::config::ProcessRole;
9714
9715 let mut config = AutumnConfig::default();
9718 let state = build_state(
9719 &config,
9720 #[cfg(feature = "db")]
9721 None,
9722 #[cfg(feature = "db")]
9723 None,
9724 #[cfg(feature = "ws")]
9725 None,
9726 );
9727 assert_eq!(state.role(), ProcessRole::Combined);
9728 assert!(state.role().serves_http());
9729 assert!(state.role().runs_workers());
9730
9731 config.role = ProcessRole::Worker;
9734 let state = build_state(
9735 &config,
9736 #[cfg(feature = "db")]
9737 None,
9738 #[cfg(feature = "db")]
9739 None,
9740 #[cfg(feature = "ws")]
9741 None,
9742 );
9743 assert_eq!(state.role(), ProcessRole::Worker);
9744 assert!(state.role().runs_workers());
9745 assert!(!state.role().serves_http());
9746 }
9747
9748 #[cfg(feature = "db")]
9749 #[tokio::test]
9750 async fn custom_pool_provider_preserves_configured_replica_topology() {
9751 struct PassthroughPoolProvider;
9752
9753 impl crate::db::DatabasePoolProvider for PassthroughPoolProvider {
9754 async fn create_pool(
9755 &self,
9756 config: &crate::config::DatabaseConfig,
9757 ) -> Result<
9758 Option<
9759 diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9760 >,
9761 crate::db::PoolError,
9762 > {
9763 crate::db::create_pool(config)
9764 }
9765 }
9766
9767 let mut config = AutumnConfig::default();
9768 config.database.primary_url = Some("postgres://localhost/primary".to_owned());
9769 config.database.primary_pool_size = Some(5);
9770 config.database.replica_url = Some("postgres://localhost/replica".to_owned());
9771 config.database.replica_pool_size = Some(2);
9772 config.database.replica_fallback = crate::config::ReplicaFallback::FailReadiness;
9773 let AppBuilder {
9774 pool_provider_factory,
9775 ..
9776 } = app().with_pool_provider(PassthroughPoolProvider);
9777
9778 let database = setup_database(
9779 &config,
9780 Vec::new(),
9781 pool_provider_factory,
9782 None,
9783 None,
9784 false,
9785 RepositoryCommitHookQueueMigrationMode::Runtime,
9786 )
9787 .await
9788 .expect("custom provider should build database topology");
9789 let topology = database.topology.expect("database should be configured");
9790
9791 assert_eq!(topology.primary().status().max_size, 5);
9792 assert_eq!(
9793 topology
9794 .replica()
9795 .expect("custom provider should create replica pool")
9796 .status()
9797 .max_size,
9798 2
9799 );
9800
9801 let state = build_state(
9802 &config,
9803 Some(&topology),
9804 None,
9805 #[cfg(feature = "ws")]
9806 None,
9807 );
9808 state
9809 .probes()
9810 .mark_replica_connection_unready("replica connection failed");
9811
9812 assert!(state.read_pool().is_none());
9813 let (status, _) = crate::probe::readiness_response(&state).await;
9814 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
9815 }
9816
9817 #[cfg(feature = "sqlite")]
9836 #[tokio::test]
9837 async fn custom_pool_provider_with_established_sqlite_pool_fails_closed_on_statement_timeout() {
9838 struct RealSqlitePoolProvider;
9841
9842 impl crate::db::DatabasePoolProvider for RealSqlitePoolProvider {
9843 async fn create_pool(
9844 &self,
9845 config: &crate::config::DatabaseConfig,
9846 ) -> Result<
9847 Option<
9848 diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9849 >,
9850 crate::db::PoolError,
9851 > {
9852 let mut relaxed = config.clone();
9857 relaxed.statement_timeout = None;
9858 crate::db::create_pool(&relaxed)
9859 }
9860 }
9861
9862 let mut config = AutumnConfig::default();
9863 config.database.primary_url = Some("sqlite::memory:".to_owned());
9864 config.database.statement_timeout = Some(std::time::Duration::from_secs(30));
9865 let AppBuilder {
9866 pool_provider_factory,
9867 shard_provider_factory,
9868 ..
9869 } = app().with_pool_provider(RealSqlitePoolProvider);
9870
9871 let Err(err) = setup_database(
9872 &config,
9873 Vec::new(),
9874 pool_provider_factory,
9875 shard_provider_factory,
9876 None,
9877 false,
9878 RepositoryCommitHookQueueMigrationMode::Runtime,
9879 )
9880 .await
9881 else {
9882 panic!(
9883 "sqlite + statement_timeout must fail closed once the provider establishes a pool"
9884 );
9885 };
9886 assert!(
9887 err.contains("database.statement_timeout") && err.contains("SQLite"),
9888 "dispatch guard error must name the config key and SQLite, got: {err}"
9889 );
9890 }
9891
9892 #[cfg(feature = "sqlite")]
9897 #[tokio::test]
9898 async fn custom_pool_provider_no_database_mode_boots_with_statement_timeout() {
9899 struct NoDatabaseProvider;
9900
9901 impl crate::db::DatabasePoolProvider for NoDatabaseProvider {
9902 async fn create_pool(
9903 &self,
9904 _config: &crate::config::DatabaseConfig,
9905 ) -> Result<
9906 Option<
9907 diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9908 >,
9909 crate::db::PoolError,
9910 > {
9911 Ok(None)
9913 }
9914 }
9915
9916 let mut config = AutumnConfig::default();
9917 config.database.primary_url = Some("sqlite::memory:".to_owned());
9919 config.database.statement_timeout = Some(std::time::Duration::from_secs(30));
9920 let AppBuilder {
9921 pool_provider_factory,
9922 shard_provider_factory,
9923 ..
9924 } = app().with_pool_provider(NoDatabaseProvider);
9925
9926 let bootstrap = setup_database(
9927 &config,
9928 Vec::new(),
9929 pool_provider_factory,
9930 shard_provider_factory,
9931 None,
9932 false,
9933 RepositoryCommitHookQueueMigrationMode::Runtime,
9934 )
9935 .await
9936 .expect("no-database provider must boot even with a nonzero statement_timeout");
9937 assert!(
9938 bootstrap.topology.is_none(),
9939 "no-database mode must yield no control topology"
9940 );
9941 assert!(
9942 bootstrap.shards.is_none(),
9943 "no-database mode must yield no shard set"
9944 );
9945 }
9946
9947 #[cfg(feature = "db")]
9948 fn sharded_test_config() -> AutumnConfig {
9949 let mut config = AutumnConfig::default();
9950 config.database.primary_url = Some("postgres://localhost/control".to_owned());
9951 config.database.shards = vec![
9952 crate::config::ShardConfig {
9953 name: "shard0".to_owned(),
9954 primary_url: "postgres://localhost/shard0".to_owned(),
9955 slots: Some(vec![crate::config::SlotSpec::Range("0-8191".to_owned())]),
9956 replica_url: None,
9957 primary_pool_size: Some(3),
9958 replica_pool_size: None,
9959 replica_fallback: None,
9960 },
9961 crate::config::ShardConfig {
9962 name: "shard1".to_owned(),
9963 primary_url: "postgres://localhost/shard1".to_owned(),
9964 slots: Some(vec![crate::config::SlotSpec::Range(
9965 "8192-16383".to_owned(),
9966 )]),
9967 replica_url: Some("postgres://localhost/shard1_ro".to_owned()),
9968 primary_pool_size: None,
9969 replica_pool_size: Some(2),
9970 replica_fallback: None,
9971 },
9972 ];
9973 config
9974 }
9975
9976 #[cfg(feature = "db")]
9977 #[tokio::test]
9978 async fn setup_database_builds_shard_set_from_config() {
9979 let config = sharded_test_config();
9980
9981 let database = setup_database(
9982 &config,
9983 Vec::new(),
9984 None,
9985 None,
9986 None,
9987 false,
9988 RepositoryCommitHookQueueMigrationMode::Runtime,
9989 )
9990 .await
9991 .expect("sharded config should bootstrap");
9992
9993 assert!(database.topology.is_some(), "control role configured");
9994 let shards = database.shards.expect("shards configured");
9995 assert_eq!(shards.len(), 2);
9996 assert_eq!(
9997 shards
9998 .by_name("shard0")
9999 .expect("shard0")
10000 .primary_pool()
10001 .status()
10002 .max_size,
10003 3
10004 );
10005 assert_eq!(
10006 shards
10007 .by_name("shard1")
10008 .expect("shard1")
10009 .replica_pool()
10010 .expect("shard1 replica")
10011 .status()
10012 .max_size,
10013 2
10014 );
10015
10016 let state = build_state(
10017 &config,
10018 database.topology.as_ref(),
10019 Some(shards),
10020 #[cfg(feature = "ws")]
10021 None,
10022 );
10023 let state_shards = state.shards().expect("state should expose shards");
10024 assert_eq!(state_shards.len(), 2);
10025 let routed = state_shards.route("tenant-1").await.expect("route");
10027 assert!(["shard0", "shard1"].contains(&routed.name()));
10028 }
10029
10030 #[cfg(feature = "db")]
10031 #[tokio::test]
10032 async fn custom_pool_provider_builds_shard_topologies() {
10033 struct CountingProvider(std::sync::Arc<std::sync::atomic::AtomicUsize>);
10034
10035 impl crate::db::DatabasePoolProvider for CountingProvider {
10036 async fn create_pool(
10037 &self,
10038 config: &crate::config::DatabaseConfig,
10039 ) -> Result<
10040 Option<
10041 diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
10042 >,
10043 crate::db::PoolError,
10044 > {
10045 crate::db::create_pool(config)
10046 }
10047
10048 async fn create_shard_topology(
10049 &self,
10050 shard: &crate::config::ShardConfig,
10051 defaults: &crate::config::DatabaseConfig,
10052 ) -> Result<crate::db::DatabaseTopology, crate::db::PoolError> {
10053 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10054 crate::db::create_shard_topology(shard, defaults)
10055 }
10056 }
10057
10058 let config = sharded_test_config();
10059 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10060 let AppBuilder {
10061 pool_provider_factory,
10062 shard_provider_factory,
10063 ..
10064 } = app().with_pool_provider(CountingProvider(calls.clone()));
10065
10066 let database = setup_database(
10067 &config,
10068 Vec::new(),
10069 pool_provider_factory,
10070 shard_provider_factory,
10071 None,
10072 false,
10073 RepositoryCommitHookQueueMigrationMode::Runtime,
10074 )
10075 .await
10076 .expect("provider should build shard topologies");
10077
10078 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
10079 assert_eq!(database.shards.expect("shards").len(), 2);
10080 }
10081
10082 #[cfg(feature = "db")]
10083 #[test]
10084 fn repository_commit_hook_worker_starts_after_job_runtime_initialization() {
10085 let source = include_str!("app.rs").replace("\r\n", "\n");
10086 let server_init = "initialize_job_runtime(
10087 jobs,
10088 &state,
10089 &server_shutdown,";
10090 let server_worker = "start_repository_commit_hook_worker(\n pool,\n server_shutdown.child_token(),\n );";
10091 let task_init = "initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)";
10092 let task_worker = "start_repository_commit_hook_worker(\n pool,\n task_shutdown.child_token(),\n );";
10093
10094 assert!(
10095 source
10096 .find(server_init)
10097 .expect("normal server path should initialize jobs")
10098 < source
10099 .find(server_worker)
10100 .expect("normal server path should start repository hook worker"),
10101 "normal server startup must initialize jobs before repository commit hooks can enqueue them"
10102 );
10103 assert!(
10104 source
10105 .find(task_init)
10106 .expect("task runner path should initialize jobs")
10107 < source
10108 .find(task_worker)
10109 .expect("task runner path should start repository hook worker"),
10110 "task runner startup must initialize jobs before repository commit hooks can enqueue them"
10111 );
10112 }
10113
10114 #[cfg(feature = "db")]
10115 #[test]
10116 fn repository_commit_hook_workers_are_gated_on_worker_role() {
10117 let source = include_str!("app.rs").replace("\r\n", "\n");
10122 let primary_gate =
10123 "if role.runs_workers()\n && let Some(pool) = state.pool().cloned()";
10124 let shard_gate = "if role.runs_workers()\n && let Some(shards) = state.shards()";
10125 assert!(
10126 source.contains(primary_gate),
10127 "primary-pool commit-hook worker must be gated on role.runs_workers()"
10128 );
10129 assert!(
10130 source.contains(shard_gate),
10131 "shard commit-hook workers must be gated on role.runs_workers()"
10132 );
10133 }
10134
10135 #[test]
10136 fn state_initializers_run_before_job_runtime_initialization() {
10137 let source = include_str!("app.rs").replace("\r\n", "\n");
10138 let server_start = source
10139 .find("pub async fn run(self)")
10140 .expect("normal server path should exist");
10141 let build_mode_start = source
10142 .find("async fn run_build_mode(self)")
10143 .expect("static build path should follow server path");
10144 let task_start = source
10145 .find("async fn run_one_off_task_mode(self, requested_name: String)")
10146 .expect("task runner path should exist");
10147 let server_source = &source[server_start..build_mode_start];
10148 let task_source = &source[task_start..];
10149 let server_init = "initialize_job_runtime(
10150 jobs,
10151 &state,
10152 &server_shutdown,";
10153 let task_init = "initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)";
10154 let server_initializer = server_source
10155 .find("run_state_initializers(state_initializers, &state);")
10156 .expect("normal server path should run state initializers");
10157 let task_initializer = task_source
10158 .find("run_state_initializers(state_initializers, &state);")
10159 .expect("task runner path should run state initializers");
10160 let server_job = server_source
10161 .find(server_init)
10162 .expect("normal server path should initialize jobs");
10163 let task_job = task_source
10164 .find(task_init)
10165 .expect("task runner path should initialize jobs");
10166
10167 assert!(
10168 server_initializer < server_job,
10169 "normal server startup must install state-initialized resources before job workers start"
10170 );
10171 assert!(
10172 task_initializer < task_job,
10173 "task runner startup must install state-initialized resources before job workers start"
10174 );
10175 }
10176
10177 #[test]
10178 fn static_builds_run_state_initializers_before_router_build() {
10179 let source = include_str!("app.rs").replace("\r\n", "\n");
10180 let build_mode_start = source
10181 .find("async fn run_build_mode(self)")
10182 .expect("static build path should exist");
10183 let dump_mode_start = source
10184 .find("async fn run_dump_routes_mode(self)")
10185 .expect("route dump path should follow static build path");
10186 let build_mode_source = &source[build_mode_start..dump_mode_start];
10187 let state_initializer = build_mode_source
10188 .find("run_state_initializers(state_initializers, &state);")
10189 .expect("static build path should run state initializers");
10190 let router_build = build_mode_source
10191 .find("let router = crate::router::try_build_router_inner(")
10192 .expect("static build path should build a router");
10193
10194 assert!(
10195 state_initializer < router_build,
10196 "static builds must install state-initialized resources before rendering routes"
10197 );
10198 }
10199
10200 #[test]
10201 fn migrate_only_one_shot_applies_and_exits_without_serving() {
10202 let source = include_str!("app.rs").replace("\r\n", "\n");
10210 let run_start = source.find("pub async fn run(self)").expect("run() exists");
10211 let run_end = source
10212 .find("async fn run_build_mode(self)")
10213 .expect("build mode follows run()");
10214 let run_body = &source[run_start..run_end];
10215
10216 let dispatch = run_body
10220 .find("if is_migrate_only_mode() {")
10221 .expect("run() dispatches the migrate one-shot");
10222 let server_start = run_body
10223 .find("let Self {")
10224 .expect("run() destructures self to start the server");
10225 assert!(
10226 dispatch < server_start,
10227 "AUTUMN_MIGRATE must be handled before the server-start path"
10228 );
10229 let migrate_branch = &run_body[dispatch..server_start];
10230 assert!(
10231 migrate_branch.contains("self.run_migrate_only_mode().await;")
10232 && migrate_branch.contains("return;"),
10233 "the migrate one-shot must run then return before server start"
10234 );
10235
10236 let handler_start = source
10238 .find("async fn run_migrate_only_mode(self)")
10239 .expect("migrate handler exists");
10240 let handler_end = source
10241 .find("async fn run_one_off_task_mode(self, requested_name: String)")
10242 .expect("one-off task handler follows the migrate handler");
10243 let handler = &source[handler_start..handler_end];
10244 assert!(
10245 handler.contains("apply_pending_or_exit"),
10246 "the migrate handler applies pending migrations per target"
10247 );
10248 assert!(
10249 handler.contains("std::process::exit(0)"),
10250 "the migrate handler exits after applying"
10251 );
10252
10253 let guard_call = handler
10259 .find("sqlite_sharding_unsupported_guard(")
10260 .expect("migrate handler applies the SQLite sharding guard");
10261 let first_apply = handler
10262 .find("apply_pending_or_exit")
10263 .expect("migrate handler applies per target");
10264 assert!(
10265 guard_call < first_apply,
10266 "the SQLite guard must run BEFORE the migration loop / apply_pending_or_exit"
10267 );
10268 assert!(
10269 !handler.contains("initialize_job_runtime")
10270 && !handler.contains("try_build_router_inner"),
10271 "the migrate one-shot must not start the server"
10272 );
10273
10274 let helper_start = source
10278 .find("fn apply_pending_or_exit(")
10279 .expect("apply_pending_or_exit exists");
10280 let helper = &source[helper_start..helper_start + 1200];
10281 assert!(
10282 helper.contains("crate::migrate::run_pending_locked("),
10283 "must reuse the shared locked applier, not duplicate migration logic"
10284 );
10285 assert!(
10286 helper.contains("std::process::exit(1)"),
10287 "a failed migration must exit non-zero (abort before cutover)"
10288 );
10289 }
10290
10291 #[cfg(feature = "db")]
10292 #[test]
10293 fn hooked_repository_apps_include_hook_queue_framework_migration() {
10294 let migrations = migrations_with_repository_framework_migrations(
10295 vec![APP_TEST_MIGRATIONS],
10296 true,
10297 false,
10298 RepositoryCommitHookQueueMigrationMode::Runtime,
10299 );
10300 let names = migration_names(&migrations);
10301
10302 assert!(
10303 names
10304 .iter()
10305 .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10306 "hooked repository apps must auto-register the durable hook queue migration"
10307 );
10308 assert!(
10309 names.iter().all(|name| !name.contains("api_tokens")),
10310 "hooked repository apps must not auto-register unrelated framework migrations: {names:?}"
10311 );
10312 }
10313
10314 #[cfg(feature = "db")]
10315 #[test]
10316 fn runtime_hooked_apps_include_hook_queue_framework_migration_without_app_migrations() {
10317 let migrations = migrations_with_repository_framework_migrations(
10318 Vec::new(),
10319 true,
10320 false,
10321 RepositoryCommitHookQueueMigrationMode::Runtime,
10322 );
10323 let names = migration_names(&migrations);
10324
10325 assert!(
10326 names
10327 .iter()
10328 .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10329 "runtime hooked repository apps must install the durable hook queue even when app migrations are managed elsewhere"
10330 );
10331 }
10332
10333 #[cfg(feature = "db")]
10334 #[test]
10335 fn versioned_repository_apps_include_version_history_framework_migration() {
10336 let migrations = migrations_with_repository_framework_migrations(
10337 vec![APP_TEST_MIGRATIONS],
10338 false,
10339 true,
10340 RepositoryCommitHookQueueMigrationMode::Runtime,
10341 );
10342 let names = migration_names(&migrations);
10343
10344 assert!(
10345 names.iter().any(|name| name == VERSION_HISTORY_MIGRATION),
10346 "versioned repository apps must auto-register the version-history migration"
10347 );
10348 assert!(
10349 names
10350 .iter()
10351 .all(|name| !name.contains("repository_commit_hook_queue")),
10352 "versioned-only repository apps must not auto-register the durable hook queue: {names:?}"
10353 );
10354 }
10355
10356 #[cfg(feature = "db")]
10357 #[test]
10358 fn runtime_versioned_apps_include_version_history_framework_migration_without_app_migrations() {
10359 let migrations = migrations_with_repository_framework_migrations(
10360 Vec::new(),
10361 false,
10362 true,
10363 RepositoryCommitHookQueueMigrationMode::Runtime,
10364 );
10365 let names = migration_names(&migrations);
10366
10367 assert!(
10368 names.iter().any(|name| name == VERSION_HISTORY_MIGRATION),
10369 "runtime versioned repository apps must install version history even when app migrations are managed elsewhere"
10370 );
10371 }
10372
10373 #[cfg(feature = "db")]
10374 #[test]
10375 fn static_builds_do_not_auto_add_hook_queue_when_no_migrations_registered() {
10376 let migrations = migrations_with_repository_framework_migrations(
10377 Vec::new(),
10378 true,
10379 true,
10380 RepositoryCommitHookQueueMigrationMode::StaticBuild,
10381 );
10382
10383 assert!(
10384 migrations.is_empty(),
10385 "static/export builds that pass no migrations must not mutate the database"
10386 );
10387 }
10388
10389 #[cfg(feature = "db")]
10390 #[test]
10391 fn directory_migration_required_only_at_runtime_with_shards_and_routing() {
10392 use RepositoryCommitHookQueueMigrationMode::{Runtime, StaticBuild};
10393
10394 assert!(directory_migration_is_required(true, true, Runtime));
10396
10397 assert!(!directory_migration_is_required(true, true, StaticBuild));
10400
10401 assert!(!directory_migration_is_required(false, true, Runtime));
10403 assert!(!directory_migration_is_required(true, false, Runtime));
10404 }
10405
10406 #[test]
10407 fn shard_map_migration_required_only_at_runtime_with_shards() {
10408 use RepositoryCommitHookQueueMigrationMode::{Runtime, StaticBuild};
10409
10410 assert!(shard_map_migration_is_required(true, Runtime));
10412
10413 assert!(!shard_map_migration_is_required(true, StaticBuild));
10415
10416 assert!(!shard_map_migration_is_required(false, Runtime));
10418 }
10419
10420 #[cfg(feature = "db")]
10421 #[test]
10422 fn unhooked_apps_do_not_auto_add_hook_queue_framework_migration() {
10423 let migrations = migrations_with_repository_framework_migrations(
10424 Vec::new(),
10425 false,
10426 false,
10427 RepositoryCommitHookQueueMigrationMode::Runtime,
10428 );
10429
10430 assert!(
10431 migrations.is_empty(),
10432 "unhooked apps should not get durable hook queue migrations for free"
10433 );
10434 }
10435
10436 #[cfg(feature = "db")]
10437 fn migration_names(migrations: &[crate::migrate::EmbeddedMigrations]) -> Vec<String> {
10438 use diesel::migration::{Migration, MigrationSource as _};
10439 use diesel::pg::Pg;
10440
10441 migrations
10442 .iter()
10443 .flat_map(|source| {
10444 let migrations: Vec<Box<dyn Migration<Pg>>> = source.migrations().unwrap();
10445 migrations
10446 })
10447 .map(|migration| migration.name().to_string())
10448 .collect()
10449 }
10450
10451 #[cfg(feature = "db")]
10452 #[test]
10453 fn control_framework_filter_skips_control_but_keeps_shard_required_sets() {
10454 assert!(migration_set_is_control_framework(
10456 &crate::migrate::FRAMEWORK_MIGRATIONS
10457 ));
10458 assert!(!migration_set_is_control_framework(
10462 &crate::version_history::VERSION_HISTORY_MIGRATIONS
10463 ));
10464 assert!(!migration_set_is_control_framework(
10465 &crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS
10466 ));
10467 }
10468
10469 #[cfg(feature = "db")]
10470 #[test]
10471 fn sharded_app_with_full_framework_still_gets_shard_required_sets() {
10472 use diesel::migration::{Migration, MigrationSource as _};
10473 use diesel::pg::Pg;
10474
10475 let migrations = migrations_with_repository_framework_migrations(
10482 vec![crate::migrate::FRAMEWORK_MIGRATIONS],
10483 true,
10484 true,
10485 RepositoryCommitHookQueueMigrationMode::Runtime,
10486 );
10487
10488 let shard_names: Vec<String> = migrations
10491 .iter()
10492 .filter(|set| !migration_set_is_control_framework(set))
10493 .flat_map(|set| {
10494 let ms: Vec<Box<dyn Migration<Pg>>> = set.migrations().unwrap_or_default();
10495 ms.into_iter()
10496 .map(|m| m.name().to_string())
10497 .collect::<Vec<_>>()
10498 })
10499 .collect();
10500
10501 assert!(
10502 shard_names
10503 .iter()
10504 .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10505 "shards must receive the commit-hook queue migration even when the full \
10506 control framework set is also registered: {shard_names:?}"
10507 );
10508 assert!(
10509 shard_names
10510 .iter()
10511 .any(|name| name == VERSION_HISTORY_MIGRATION),
10512 "shards must receive the version-history migration even when the full \
10513 control framework set is also registered: {shard_names:?}"
10514 );
10515 }
10516
10517 #[cfg(feature = "db")]
10518 #[test]
10519 fn configure_replica_migration_check_stores_recheck_urls() {
10520 let mut config = AutumnConfig::default();
10521 config.database.primary_url = Some("postgres://localhost/primary".to_owned());
10522 config.database.replica_url = Some("postgres://localhost/replica".to_owned());
10523 let topology = crate::db::create_topology(&config.database)
10524 .expect("topology should build")
10525 .expect("database should be configured");
10526
10527 let state = build_state(
10528 &config,
10529 Some(&topology),
10530 None,
10531 #[cfg(feature = "ws")]
10532 None,
10533 );
10534
10535 assert!(
10536 state.probes().replica_migration_check().is_none(),
10537 "build_state should not enable migration checks without registered migrations"
10538 );
10539
10540 configure_replica_migration_check(
10541 &state,
10542 Some((
10543 "postgres://localhost/primary".to_owned(),
10544 "postgres://localhost/replica".to_owned(),
10545 )),
10546 );
10547
10548 let check = state
10549 .probes()
10550 .replica_migration_check()
10551 .expect("replica migration check should be configured");
10552
10553 assert_eq!(check.primary_url, "postgres://localhost/primary");
10554 assert_eq!(check.replica_url, "postgres://localhost/replica");
10555 }
10556
10557 #[cfg(feature = "db")]
10558 #[tokio::test]
10559 async fn replica_migration_readiness_marks_ready_endpoint_degraded() {
10560 let mut config = AutumnConfig::default();
10561 config.database.primary_url = Some("postgres://localhost/primary".to_owned());
10562 config.database.primary_pool_size = Some(5);
10563 config.database.replica_url = Some("postgres://localhost/replica".to_owned());
10564 config.database.replica_pool_size = Some(2);
10565 config.database.replica_fallback = crate::config::ReplicaFallback::FailReadiness;
10566 let topology = crate::db::create_topology(&config.database)
10567 .expect("topology should build")
10568 .expect("database should be configured");
10569 let state = build_state(
10570 &config,
10571 Some(&topology),
10572 None,
10573 #[cfg(feature = "ws")]
10574 None,
10575 );
10576
10577 apply_replica_migration_readiness(
10578 &state,
10579 Some(crate::migrate::ReplicaMigrationReadiness::Stale {
10580 primary_latest: Some("00000000000002".to_owned()),
10581 replica_latest: Some("00000000000001".to_owned()),
10582 }),
10583 );
10584
10585 let (status, _) = crate::probe::readiness_response(&state).await;
10586
10587 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
10588 }
10589
10590 #[cfg(feature = "db")]
10591 #[tokio::test]
10592 async fn blocking_replica_migration_readiness_reports_unknown_connection_errors() {
10593 let readiness = crate::migrate::check_replica_migration_readiness_blocking(
10594 "not-a-primary-url".to_owned(),
10595 "not-a-replica-url".to_owned(),
10596 )
10597 .await;
10598
10599 assert!(matches!(
10600 readiness,
10601 crate::migrate::ReplicaMigrationReadiness::Unknown(_)
10602 ));
10603 }
10604
10605 #[cfg(feature = "ws")]
10606 #[test]
10607 fn with_channels_backend_overrides_config_driven_backend_selection() {
10608 let builder = app().with_channels_backend(crate::channels::LocalChannelsBackend::new(4));
10609 let AppBuilder {
10610 channels_backend, ..
10611 } = builder;
10612 assert!(channels_backend.is_some());
10613
10614 let mut config = AutumnConfig::default();
10615 config.channels.backend = crate::config::ChannelBackend::Redis;
10616 config.channels.redis.url = None;
10617
10618 let state = build_state(
10619 &config,
10620 #[cfg(feature = "db")]
10621 None,
10622 #[cfg(feature = "db")]
10623 None,
10624 #[cfg(feature = "ws")]
10625 channels_backend,
10626 );
10627 let mut rx = state.channels().subscribe("override");
10628
10629 state
10630 .broadcast()
10631 .publish("override", "ok")
10632 .expect("custom local backend should publish");
10633
10634 assert_eq!(rx.try_recv().expect("message should arrive").as_str(), "ok");
10635 }
10636
10637 pub fn test_get_route(path: &'static str, name: &'static str) -> Route {
10639 Route {
10640 method: http::Method::GET,
10641 path,
10642 handler: axum::routing::get(|| async { "ok" }),
10643 name,
10644 api_doc: crate::openapi::ApiDoc {
10645 method: "GET",
10646 path,
10647 operation_id: name,
10648 success_status: 200,
10649 ..Default::default()
10650 },
10651 repository: None,
10652 idempotency: crate::route::RouteIdempotency::Direct,
10653 timeout: crate::route::RouteTimeout::Inherit,
10654 api_version: None,
10655 sunset_opt_out: false,
10656 }
10657 }
10658
10659 #[cfg(feature = "i18n")]
10660 fn test_i18n_bundle(key: &str, value: &str) -> Arc<crate::i18n::Bundle> {
10661 let mut messages = std::collections::HashMap::new();
10662 let mut en = std::collections::HashMap::new();
10663 en.insert(key.to_owned(), value.to_owned());
10664 messages.insert("en".to_owned(), en);
10665 Arc::new(crate::i18n::Bundle::from_messages(
10666 messages,
10667 &crate::i18n::I18nConfig::default(),
10668 ))
10669 }
10670
10671 #[cfg(feature = "i18n")]
10672 #[test]
10673 fn i18n_auto_defers_loading_until_runtime_config_is_available() {
10674 let builder = app().i18n_auto();
10675
10676 assert!(builder.i18n_bundle.is_none());
10677 assert!(builder.i18n_auto_load);
10678 }
10679
10680 #[cfg(feature = "i18n")]
10681 #[derive(Clone)]
10682 struct StaticConfigLoader {
10683 config: AutumnConfig,
10684 }
10685
10686 #[cfg(feature = "i18n")]
10687 impl crate::config::ConfigLoader for StaticConfigLoader {
10688 async fn load(&self) -> Result<AutumnConfig, crate::config::ConfigError> {
10689 Ok(self.config.clone())
10690 }
10691 }
10692
10693 #[cfg(feature = "i18n")]
10694 struct NoopTelemetryProvider;
10695
10696 #[cfg(feature = "i18n")]
10697 impl crate::telemetry::TelemetryProvider for NoopTelemetryProvider {
10698 fn init(
10699 &self,
10700 _log: &crate::config::LogConfig,
10701 _telemetry: &crate::config::TelemetryConfig,
10702 _profile: Option<&str>,
10703 ) -> Result<crate::telemetry::TelemetryGuard, crate::telemetry::TelemetryInitError>
10704 {
10705 Ok(crate::telemetry::TelemetryGuard::disabled())
10706 }
10707 }
10708
10709 #[cfg(feature = "i18n")]
10710 #[tokio::test]
10711 async fn i18n_auto_uses_config_loader_output_for_bundle_dir() {
10712 let project = tempfile::tempdir().expect("project dir");
10713 let i18n_dir = project.path().join("custom-i18n");
10714 std::fs::create_dir_all(&i18n_dir).expect("i18n dir");
10715 std::fs::write(i18n_dir.join("en.ftl"), "nav.home = Loader Home\n").expect("bundle");
10716
10717 let mut config = AutumnConfig::default();
10718 config.i18n.dir = "custom-i18n".to_owned();
10719 let builder = app()
10720 .with_config_loader(StaticConfigLoader { config })
10721 .with_telemetry_provider(NoopTelemetryProvider)
10722 .i18n_auto();
10723 let AppBuilder {
10724 config_loader_factory,
10725 telemetry_provider,
10726 i18n_bundle,
10727 i18n_auto_load,
10728 plugin_config_roots,
10729 ..
10730 } = builder;
10731
10732 let (loaded_config, _guard) = load_config_and_telemetry(
10733 config_loader_factory,
10734 telemetry_provider,
10735 plugin_config_roots,
10736 )
10737 .await;
10738 let env = crate::config::MockEnv::new().with(
10739 "AUTUMN_MANIFEST_DIR",
10740 project.path().to_str().expect("utf-8 path"),
10741 );
10742 let bundle = resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &loaded_config, &env)
10743 .expect("bundle loaded from configured dir");
10744
10745 assert_eq!(bundle.translate("en", "nav.home", &[]), "Loader Home");
10746 }
10747
10748 #[cfg(feature = "i18n")]
10749 #[tokio::test]
10750 async fn i18n_bundle_layer_is_applied_to_static_route_rendering() {
10751 async fn localized(locale: crate::i18n::Locale) -> String {
10752 locale.t("nav.home")
10753 }
10754
10755 let config = AutumnConfig::default();
10756 let state = AppState::for_test();
10757 let custom_layers = install_i18n_bundle_layer(
10758 Vec::new(),
10759 &state,
10760 Some(test_i18n_bundle("nav.home", "Home")),
10761 );
10762 let router = crate::router::try_build_router_inner(
10763 vec![Route {
10764 method: http::Method::GET,
10765 path: "/about",
10766 handler: axum::routing::get(localized),
10767 name: "localized",
10768 api_doc: crate::openapi::ApiDoc {
10769 method: "GET",
10770 path: "/about",
10771 operation_id: "localized",
10772 success_status: 200,
10773 ..Default::default()
10774 },
10775 repository: None,
10776 idempotency: crate::route::RouteIdempotency::Direct,
10777 timeout: crate::route::RouteTimeout::Inherit,
10778 api_version: None,
10779 sunset_opt_out: false,
10780 }],
10781 &config,
10782 state,
10783 crate::router::RouterContext {
10784 exception_filters: Vec::new(),
10785 scoped_groups: Vec::new(),
10786 merge_routers: Vec::new(),
10787 nest_routers: Vec::new(),
10788 custom_layers,
10789 static_gate_layers: Vec::new(),
10790 #[cfg(feature = "maud")]
10791 error_page_renderer: None,
10792 session_store: None,
10793 #[cfg(feature = "openapi")]
10794 openapi: None,
10795 #[cfg(feature = "mcp")]
10796 mcp: None,
10797 },
10798 )
10799 .expect("router builds");
10800 let tmp = tempfile::tempdir().expect("dist parent");
10801 let dist = tmp.path().join("dist");
10802
10803 crate::static_gen::render_static_routes(
10804 router,
10805 &[crate::static_gen::StaticRouteMeta {
10806 path: "/about",
10807 name: "localized",
10808 revalidate: None,
10809 params_fn: None,
10810 }],
10811 &dist,
10812 )
10813 .await
10814 .expect("static render succeeds");
10815
10816 let html = std::fs::read_to_string(dist.join("about/index.html")).expect("rendered html");
10817 assert_eq!(html, "Home");
10818 }
10819
10820 #[test]
10821 fn app_builder_routes_adds_routes() {
10822 let builder = app();
10823 assert_eq!(builder.routes.len(), 0);
10824
10825 let builder = builder.routes(vec![test_get_route("/1", "route1")]);
10826 assert_eq!(builder.routes.len(), 1);
10827
10828 let builder = builder.routes(vec![
10829 test_get_route("/2", "route2"),
10830 test_get_route("/3", "route3"),
10831 ]);
10832 assert_eq!(builder.routes.len(), 3);
10833
10834 assert_eq!(builder.routes[0].path, "/1");
10835 assert_eq!(builder.routes[1].path, "/2");
10836 assert_eq!(builder.routes[2].path, "/3");
10837 }
10838
10839 #[test]
10840 fn app_builder_extensions_store_and_update_typed_values() {
10841 let builder = app()
10842 .with_extension::<String>("haunted".into())
10843 .update_extension::<String, _, _>(String::new, |value| value.push_str(" harvest"));
10844
10845 let value = builder
10846 .extension::<String>()
10847 .expect("string extension should be present");
10848 assert_eq!(value, "haunted harvest");
10849 }
10850
10851 #[cfg(feature = "mail")]
10852 #[tokio::test]
10853 async fn app_builder_with_mail_delivery_queue_stores_queue_for_install() {
10854 let builder = app().with_mail_delivery_queue(MailTestNoopQueue);
10855 let factory = builder
10856 .mail_delivery_queue_factory
10857 .expect("with_mail_delivery_queue should store a factory on the builder");
10858
10859 let state = AppState::for_test();
10862 let queue = factory(&state).expect("trivial factory should produce the queue");
10863 assert!(Arc::strong_count(&queue) >= 1);
10864 queue
10866 .enqueue(test_mail())
10867 .await
10868 .expect("noop queue should always succeed");
10869 }
10870
10871 #[cfg(feature = "mail")]
10872 #[test]
10873 fn app_builder_with_mail_delivery_queue_factory_runs_with_app_state() {
10874 let observed_profile: Arc<std::sync::Mutex<Option<String>>> =
10875 Arc::new(std::sync::Mutex::new(None));
10876 let captured = Arc::clone(&observed_profile);
10877 let builder = app().with_mail_delivery_queue_factory(move |state| {
10878 *captured.lock().expect("lock") = Some(state.profile().to_owned());
10879 Ok::<_, crate::AutumnError>(MailTestNoopQueue)
10880 });
10881
10882 let factory = builder
10883 .mail_delivery_queue_factory
10884 .expect("factory should be stored on the builder");
10885 let state = AppState::for_test().with_profile("dev");
10886 let _queue = factory(&state).expect("factory should succeed");
10887
10888 assert_eq!(
10889 observed_profile.lock().expect("lock").as_deref(),
10890 Some("dev"),
10891 "factory must run with the live AppState"
10892 );
10893 }
10894
10895 #[cfg(feature = "mail")]
10896 #[test]
10897 fn app_builder_with_mail_delivery_queue_factory_propagates_errors() {
10898 let builder = app().with_mail_delivery_queue_factory(|_state| {
10899 Err::<MailTestNoopQueue, _>(crate::AutumnError::service_unavailable_msg("factory boom"))
10900 });
10901
10902 let factory = builder
10903 .mail_delivery_queue_factory
10904 .expect("factory present");
10905 let state = AppState::for_test();
10906 match factory(&state) {
10907 Ok(_) => panic!("factory should have errored"),
10908 Err(err) => assert!(err.to_string().contains("factory boom")),
10909 }
10910 }
10911
10912 #[tokio::test]
10913 async fn startup_and_shutdown_hooks_run_in_expected_order() {
10914 let events = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
10915 let startup_events = Arc::clone(&events);
10916 let shutdown_a = Arc::clone(&events);
10917 let shutdown_b = Arc::clone(&events);
10918 let builder = app()
10919 .on_startup(move |_state| {
10920 let startup_events = Arc::clone(&startup_events);
10921 async move {
10922 startup_events
10923 .lock()
10924 .expect("events lock poisoned")
10925 .push("start");
10926 Ok(())
10927 }
10928 })
10929 .on_shutdown(move || {
10930 let shutdown_a = Arc::clone(&shutdown_a);
10931 async move {
10932 shutdown_a
10933 .lock()
10934 .expect("events lock poisoned")
10935 .push("stop-a");
10936 }
10937 })
10938 .on_shutdown(move || {
10939 let shutdown_b = Arc::clone(&shutdown_b);
10940 async move {
10941 shutdown_b
10942 .lock()
10943 .expect("events lock poisoned")
10944 .push("stop-b");
10945 }
10946 });
10947
10948 run_startup_hooks(&builder.startup_hooks, AppState::for_test())
10949 .await
10950 .expect("startup hooks should succeed");
10951 run_shutdown_hooks(&builder.shutdown_hooks).await;
10952
10953 let recorded_events = events.lock().expect("events lock poisoned").clone();
10954 assert_eq!(recorded_events, vec!["start", "stop-b", "stop-a"]);
10955 }
10956
10957 fn startup_noop_job_handler(
10958 _state: AppState,
10959 _payload: serde_json::Value,
10960 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'static>> {
10961 Box::pin(async move { Ok(()) })
10962 }
10963
10964 #[tokio::test]
10965 async fn startup_hooks_can_enqueue_jobs_after_runtime_init() {
10966 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
10967 crate::job::clear_global_job_client();
10968
10969 let builder = app()
10970 .jobs(vec![crate::job::JobInfo {
10971 version: 1,
10972 name: "startup-seed".to_string(),
10973 max_attempts: 1,
10974 initial_backoff_ms: 1,
10975 queue: "default".to_string(),
10976 uniqueness: None,
10977 concurrency: None,
10978 handler: startup_noop_job_handler,
10979 }])
10980 .on_startup(|_state| async {
10981 crate::job::enqueue("startup-seed", serde_json::json!({ "kind": "warmup" })).await
10982 });
10983
10984 let state = AppState::for_test().with_profile("dev");
10985 let shutdown = tokio_util::sync::CancellationToken::new();
10986
10987 initialize_job_runtime(
10988 builder.jobs.clone(),
10989 &state,
10990 &shutdown,
10991 &crate::config::JobConfig::default(),
10992 true,
10993 )
10994 .expect("job runtime should initialize before startup hooks");
10995
10996 run_startup_hooks(&builder.startup_hooks, state.clone())
10997 .await
10998 .expect("startup hook should be able to enqueue jobs");
10999
11000 tokio::time::timeout(std::time::Duration::from_secs(1), async {
11001 loop {
11002 let snapshot = state.job_registry().snapshot();
11003 let status = snapshot
11004 .get("startup-seed")
11005 .expect("job should be registered before startup hooks run");
11006 if status.total_successes == 1 {
11007 break;
11008 }
11009 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
11010 }
11011 })
11012 .await
11013 .expect("startup-enqueued job should complete");
11014
11015 shutdown.cancel();
11016 crate::job::clear_global_job_client();
11017 }
11018
11019 #[tokio::test]
11020 async fn initialize_job_runtime_propagates_redis_init_errors() {
11021 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
11022 crate::job::clear_global_job_client();
11023
11024 let state = AppState::for_test().with_profile("dev");
11025 let shutdown = tokio_util::sync::CancellationToken::new();
11026 let config = crate::config::JobConfig {
11027 backend: "redis".to_string(),
11028 ..Default::default()
11029 };
11030
11031 let error = initialize_job_runtime(
11032 vec![crate::job::JobInfo {
11033 version: 1,
11034 name: "startup-seed".to_string(),
11035 max_attempts: 1,
11036 initial_backoff_ms: 1,
11037 queue: "default".to_string(),
11038 uniqueness: None,
11039 concurrency: None,
11040 handler: startup_noop_job_handler,
11041 }],
11042 &state,
11043 &shutdown,
11044 &config,
11045 true,
11046 )
11047 .expect_err("redis init errors should abort startup");
11048
11049 #[cfg(feature = "redis")]
11050 assert!(
11051 error
11052 .to_string()
11053 .contains("jobs.backend=redis requires jobs.redis.url"),
11054 "unexpected error: {error}"
11055 );
11056
11057 #[cfg(not(feature = "redis"))]
11058 assert!(
11059 error
11060 .to_string()
11061 .contains("jobs.backend=redis requested but redis feature is disabled"),
11062 "unexpected error: {error}"
11063 );
11064 }
11065
11066 #[tokio::test]
11067 async fn startup_hook_errors_propagate() {
11068 let builder = app().on_startup(|_state| async {
11069 Err(crate::AutumnError::service_unavailable_msg(
11070 "startup ritual failed",
11071 ))
11072 });
11073
11074 let error = run_startup_hooks(&builder.startup_hooks, AppState::for_test())
11075 .await
11076 .expect_err("startup hook should fail");
11077 assert!(error.to_string().contains("startup ritual failed"));
11078 }
11079
11080 #[tokio::test]
11081 async fn build_router_mounts_user_routes() {
11082 let router = test_router(vec![test_get_route("/test", "test_handler")]);
11083
11084 let response = router
11085 .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
11086 .await
11087 .unwrap();
11088
11089 assert_eq!(response.status(), StatusCode::OK);
11090 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11091 .await
11092 .unwrap();
11093 assert_eq!(&body[..], b"ok");
11094 }
11095
11096 #[tokio::test]
11097 async fn build_router_mounts_health_check_at_default_path() {
11098 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11099
11100 let response = router
11101 .oneshot(
11102 Request::builder()
11103 .uri("/health")
11104 .body(Body::empty())
11105 .unwrap(),
11106 )
11107 .await
11108 .unwrap();
11109
11110 assert_eq!(response.status(), StatusCode::OK);
11111 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11112 .await
11113 .unwrap();
11114 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
11115 assert_eq!(json["status"], "ok");
11116 }
11117
11118 #[tokio::test]
11119 async fn build_router_mounts_health_check_at_custom_path() {
11120 let mut config = AutumnConfig::default();
11121 config.health.path = "/healthz".to_owned();
11122 let state = AppState {
11123 extensions: std::sync::Arc::new(std::sync::RwLock::new(
11124 std::collections::HashMap::new(),
11125 )),
11126 #[cfg(feature = "db")]
11127 pool: None,
11128 #[cfg(feature = "db")]
11129 replica_pool: None,
11130 #[cfg(feature = "db")]
11131 shards: None,
11132 profile: None,
11133 role: crate::config::ProcessRole::Combined,
11134 started_at: std::time::Instant::now(),
11135 health_detailed: true,
11136 probes: crate::probe::ProbeState::ready_for_test(),
11137 metrics: crate::middleware::MetricsCollector::new(),
11138 log_levels: crate::actuator::LogLevels::new("info"),
11139 task_registry: crate::actuator::TaskRegistry::new(),
11140 job_registry: crate::actuator::JobRegistry::new(),
11141 config_props: crate::actuator::ConfigProperties::default(),
11142 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11143 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11144 #[cfg(feature = "ws")]
11145 channels: crate::channels::Channels::new(32),
11146 #[cfg(feature = "presence")]
11147 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11148 #[cfg(feature = "ws")]
11149 shutdown: tokio_util::sync::CancellationToken::new(),
11150 policy_registry: crate::authorization::PolicyRegistry::default(),
11151 forbidden_response: crate::authorization::ForbiddenResponse::default(),
11152 auth_session_key: "user_id".to_owned(),
11153 shared_cache: None,
11154 clock: std::sync::Arc::new(crate::time::SystemClock),
11155 app_id: AppState::next_app_id(),
11156 };
11157 let router =
11158 crate::router::build_router(vec![test_get_route("/dummy", "dummy")], &config, state);
11159
11160 let response = router
11161 .oneshot(
11162 Request::builder()
11163 .uri("/healthz")
11164 .body(Body::empty())
11165 .unwrap(),
11166 )
11167 .await
11168 .unwrap();
11169
11170 assert_eq!(response.status(), StatusCode::OK);
11171 }
11172
11173 #[tokio::test]
11174 async fn build_router_adds_request_id_header() {
11175 let router = test_router(vec![test_get_route("/test", "test")]);
11176
11177 let response = router
11178 .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
11179 .await
11180 .unwrap();
11181
11182 assert!(response.headers().contains_key("x-request-id"));
11183 }
11184
11185 #[tokio::test]
11186 async fn build_router_unknown_route_returns_404() {
11187 let router = test_router(vec![test_get_route("/exists", "exists")]);
11188
11189 let response = router
11190 .oneshot(Request::builder().uri("/nope").body(Body::empty()).unwrap())
11191 .await
11192 .unwrap();
11193
11194 assert_eq!(response.status(), StatusCode::NOT_FOUND);
11195 }
11196
11197 #[tokio::test]
11198 async fn build_router_multiple_routes() {
11199 let router = test_router(vec![test_get_route("/a", "a"), test_get_route("/b", "b")]);
11200
11201 let resp_a = router
11202 .clone()
11203 .oneshot(Request::builder().uri("/a").body(Body::empty()).unwrap())
11204 .await
11205 .unwrap();
11206 assert_eq!(resp_a.status(), StatusCode::OK);
11207
11208 let resp_b = router
11209 .oneshot(Request::builder().uri("/b").body(Body::empty()).unwrap())
11210 .await
11211 .unwrap();
11212 assert_eq!(resp_b.status(), StatusCode::OK);
11213 }
11214
11215 #[tokio::test]
11216 async fn build_router_post_route() {
11217 let post_routes = vec![Route {
11218 method: http::Method::POST,
11219 path: "/submit",
11220 handler: axum::routing::post(|| async { "posted" }),
11221 name: "submit",
11222 api_doc: crate::openapi::ApiDoc {
11223 method: "POST",
11224 path: "/submit",
11225 operation_id: "submit",
11226 success_status: 200,
11227 ..Default::default()
11228 },
11229 repository: None,
11230 idempotency: crate::route::RouteIdempotency::Direct,
11231 timeout: crate::route::RouteTimeout::Inherit,
11232 api_version: None,
11233 sunset_opt_out: false,
11234 }];
11235 let config = AutumnConfig::default();
11236 let state = AppState {
11237 extensions: std::sync::Arc::new(std::sync::RwLock::new(
11238 std::collections::HashMap::new(),
11239 )),
11240 #[cfg(feature = "db")]
11241 pool: None,
11242 #[cfg(feature = "db")]
11243 replica_pool: None,
11244 #[cfg(feature = "db")]
11245 shards: None,
11246 profile: None,
11247 role: crate::config::ProcessRole::Combined,
11248 started_at: std::time::Instant::now(),
11249 health_detailed: true,
11250 probes: crate::probe::ProbeState::ready_for_test(),
11251 metrics: crate::middleware::MetricsCollector::new(),
11252 log_levels: crate::actuator::LogLevels::new("info"),
11253 task_registry: crate::actuator::TaskRegistry::new(),
11254 job_registry: crate::actuator::JobRegistry::new(),
11255 config_props: crate::actuator::ConfigProperties::default(),
11256 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11257 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11258 #[cfg(feature = "ws")]
11259 channels: crate::channels::Channels::new(32),
11260 #[cfg(feature = "presence")]
11261 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11262 #[cfg(feature = "ws")]
11263 shutdown: tokio_util::sync::CancellationToken::new(),
11264 policy_registry: crate::authorization::PolicyRegistry::default(),
11265 forbidden_response: crate::authorization::ForbiddenResponse::default(),
11266 auth_session_key: "user_id".to_owned(),
11267 shared_cache: None,
11268 clock: std::sync::Arc::new(crate::time::SystemClock),
11269 app_id: AppState::next_app_id(),
11270 };
11271 let router = crate::router::build_router(post_routes, &config, state);
11272
11273 let response = router
11274 .oneshot(
11275 Request::builder()
11276 .method("POST")
11277 .uri("/submit")
11278 .body(Body::empty())
11279 .unwrap(),
11280 )
11281 .await
11282 .unwrap();
11283
11284 assert_eq!(response.status(), StatusCode::OK);
11285 }
11286
11287 #[tokio::test]
11288 async fn build_router_merges_methods_on_same_path() {
11289 let route_list = vec![
11290 Route {
11291 method: http::Method::GET,
11292 path: "/admin",
11293 handler: axum::routing::get(|| async { "list" }),
11294 name: "admin_list",
11295 api_doc: crate::openapi::ApiDoc {
11296 method: "GET",
11297 path: "/admin",
11298 operation_id: "admin_list",
11299 success_status: 200,
11300 ..Default::default()
11301 },
11302 repository: None,
11303 idempotency: crate::route::RouteIdempotency::Direct,
11304 timeout: crate::route::RouteTimeout::Inherit,
11305 api_version: None,
11306 sunset_opt_out: false,
11307 },
11308 Route {
11309 method: http::Method::POST,
11310 path: "/admin",
11311 handler: axum::routing::post(|| async { "created" }),
11312 name: "create",
11313 api_doc: crate::openapi::ApiDoc {
11314 method: "POST",
11315 path: "/admin",
11316 operation_id: "create",
11317 success_status: 200,
11318 ..Default::default()
11319 },
11320 repository: None,
11321 idempotency: crate::route::RouteIdempotency::Direct,
11322 timeout: crate::route::RouteTimeout::Inherit,
11323 api_version: None,
11324 sunset_opt_out: false,
11325 },
11326 ];
11327 let config = AutumnConfig::default();
11328 let router = crate::router::build_router(route_list, &config, AppState::for_test());
11329
11330 let resp = router
11332 .clone()
11333 .oneshot(
11334 Request::builder()
11335 .uri("/admin")
11336 .body(Body::empty())
11337 .unwrap(),
11338 )
11339 .await
11340 .unwrap();
11341 assert_eq!(resp.status(), StatusCode::OK);
11342 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11343 .await
11344 .unwrap();
11345 assert_eq!(&body[..], b"list");
11346
11347 let resp = router
11349 .oneshot(
11350 Request::builder()
11351 .method("POST")
11352 .uri("/admin")
11353 .body(Body::empty())
11354 .unwrap(),
11355 )
11356 .await
11357 .unwrap();
11358 assert_eq!(resp.status(), StatusCode::OK);
11359 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11360 .await
11361 .unwrap();
11362 assert_eq!(&body[..], b"created");
11363 }
11364
11365 #[cfg(feature = "htmx")]
11366 #[tokio::test]
11367 async fn htmx_handler_returns_javascript_with_correct_headers() {
11368 let app = axum::Router::new().route(
11369 crate::htmx::HTMX_JS_PATH,
11370 axum::routing::get(crate::router::htmx_handler),
11371 );
11372
11373 let response = app
11374 .oneshot(
11375 Request::builder()
11376 .uri(crate::htmx::HTMX_JS_PATH)
11377 .body(Body::empty())
11378 .unwrap(),
11379 )
11380 .await
11381 .unwrap();
11382
11383 assert_eq!(response.status(), StatusCode::OK);
11384
11385 let content_type = response
11386 .headers()
11387 .get("content-type")
11388 .unwrap()
11389 .to_str()
11390 .unwrap();
11391 assert!(
11392 content_type.contains("application/javascript"),
11393 "Expected application/javascript, got {content_type}"
11394 );
11395
11396 let cache_control = response
11397 .headers()
11398 .get("cache-control")
11399 .unwrap()
11400 .to_str()
11401 .unwrap();
11402 assert!(
11403 cache_control.contains("immutable"),
11404 "Expected immutable cache, got {cache_control}"
11405 );
11406
11407 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11408 .await
11409 .unwrap();
11410
11411 assert_eq!(body.len(), crate::htmx::HTMX_JS.len());
11413
11414 let start = std::str::from_utf8(&body[..50]).expect("htmx should be valid UTF-8");
11416 assert!(
11417 start.contains("htmx") || start.contains("function"),
11418 "Response doesn't look like htmx JavaScript: {start}"
11419 );
11420 }
11421
11422 #[cfg(feature = "htmx")]
11423 #[tokio::test]
11424 async fn htmx_csrf_handler_returns_csp_compatible_javascript() {
11425 let app = axum::Router::new().route(
11426 crate::htmx::HTMX_CSRF_JS_PATH,
11427 axum::routing::get(crate::router::htmx_csrf_handler),
11428 );
11429
11430 let response = app
11431 .oneshot(
11432 Request::builder()
11433 .uri(crate::htmx::HTMX_CSRF_JS_PATH)
11434 .body(Body::empty())
11435 .unwrap(),
11436 )
11437 .await
11438 .unwrap();
11439
11440 assert_eq!(response.status(), StatusCode::OK);
11441 assert_eq!(
11442 response
11443 .headers()
11444 .get("content-type")
11445 .and_then(|value| value.to_str().ok()),
11446 Some("application/javascript")
11447 );
11448
11449 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11450 .await
11451 .unwrap();
11452 let js = std::str::from_utf8(&body).expect("csrf helper should be valid utf-8");
11453
11454 assert!(js.contains("htmx:configRequest"));
11455 assert!(js.contains("X-CSRF-Token"));
11456 assert!(!js.contains("<script"));
11457 }
11458
11459 #[cfg(feature = "htmx")]
11460 #[tokio::test]
11461 async fn build_router_serves_htmx_js() {
11462 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11463
11464 let response = router
11465 .oneshot(
11466 Request::builder()
11467 .uri(crate::htmx::HTMX_JS_PATH)
11468 .body(Body::empty())
11469 .unwrap(),
11470 )
11471 .await
11472 .unwrap();
11473
11474 assert_eq!(response.status(), StatusCode::OK);
11475 let ct = response
11476 .headers()
11477 .get("content-type")
11478 .unwrap()
11479 .to_str()
11480 .unwrap();
11481 assert!(ct.contains("javascript"));
11482 }
11483
11484 #[cfg(feature = "htmx")]
11485 #[tokio::test]
11486 async fn build_router_serves_htmx_csrf_js() {
11487 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11488
11489 let response = router
11490 .oneshot(
11491 Request::builder()
11492 .uri(crate::htmx::HTMX_CSRF_JS_PATH)
11493 .body(Body::empty())
11494 .unwrap(),
11495 )
11496 .await
11497 .unwrap();
11498
11499 assert_eq!(response.status(), StatusCode::OK);
11500 let csp = response
11501 .headers()
11502 .get("content-security-policy")
11503 .expect("framework JS should still receive security headers")
11504 .to_str()
11505 .unwrap();
11506 assert!(csp.contains("script-src 'self'"), "csp = {csp}");
11507 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11508 .await
11509 .unwrap();
11510 let js = std::str::from_utf8(&body).expect("csrf helper should be valid utf-8");
11511 assert!(js.contains("htmx:configRequest"));
11512 assert!(js.contains("X-CSRF-Token"));
11513 }
11514
11515 #[tokio::test]
11516 async fn build_router_serves_default_favicon_without_404() {
11517 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11518
11519 let response = router
11520 .oneshot(
11521 Request::builder()
11522 .uri(crate::router::DEFAULT_FAVICON_PATH)
11523 .body(Body::empty())
11524 .unwrap(),
11525 )
11526 .await
11527 .unwrap();
11528
11529 assert_eq!(response.status(), StatusCode::NO_CONTENT);
11530 assert!(
11531 response.headers().contains_key("content-security-policy"),
11532 "framework fallback responses should still receive security headers"
11533 );
11534 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11535 .await
11536 .unwrap();
11537 assert!(body.is_empty());
11538 }
11539
11540 #[tokio::test]
11541 async fn build_router_does_not_override_user_favicon_route() {
11542 let router = test_router(vec![test_get_route(
11543 crate::router::DEFAULT_FAVICON_PATH,
11544 "favicon",
11545 )]);
11546
11547 let response = router
11548 .oneshot(
11549 Request::builder()
11550 .uri(crate::router::DEFAULT_FAVICON_PATH)
11551 .body(Body::empty())
11552 .unwrap(),
11553 )
11554 .await
11555 .unwrap();
11556
11557 assert_eq!(response.status(), StatusCode::OK);
11558 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11559 .await
11560 .unwrap();
11561 assert_eq!(&body[..], b"ok");
11562 }
11563
11564 #[tokio::test]
11565 async fn build_router_serves_static_files_for_unmatched_paths() {
11566 use std::collections::HashMap;
11567
11568 let tmp = tempfile::tempdir().expect("tempdir");
11570 let dist = tmp.path().join("dist");
11571 std::fs::create_dir_all(dist.join("docs")).expect("mkdir");
11572 std::fs::write(dist.join("docs/index.html"), "<h1>Static Docs</h1>").expect("write");
11573
11574 let manifest = crate::static_gen::StaticManifest {
11575 generated_at: "2026-03-27T00:00:00Z".to_owned(),
11576 autumn_version: "0.2.0".to_owned(),
11577 routes: HashMap::from([(
11578 "/docs".to_owned(),
11579 crate::static_gen::ManifestEntry {
11580 file: "docs/index.html".to_owned(),
11581 revalidate: None,
11582 },
11583 )]),
11584 };
11585 let json = serde_json::to_string(&manifest).expect("serialize");
11586 std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
11587
11588 let config = AutumnConfig::default();
11590 let state = AppState {
11591 extensions: std::sync::Arc::new(std::sync::RwLock::new(
11592 std::collections::HashMap::new(),
11593 )),
11594 #[cfg(feature = "db")]
11595 pool: None,
11596 #[cfg(feature = "db")]
11597 replica_pool: None,
11598 #[cfg(feature = "db")]
11599 shards: None,
11600 profile: None,
11601 role: crate::config::ProcessRole::Combined,
11602 started_at: std::time::Instant::now(),
11603 health_detailed: true,
11604 probes: crate::probe::ProbeState::ready_for_test(),
11605 metrics: crate::middleware::MetricsCollector::new(),
11606 log_levels: crate::actuator::LogLevels::new("info"),
11607 task_registry: crate::actuator::TaskRegistry::new(),
11608 job_registry: crate::actuator::JobRegistry::new(),
11609 config_props: crate::actuator::ConfigProperties::default(),
11610 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11611 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11612 #[cfg(feature = "ws")]
11613 channels: crate::channels::Channels::new(32),
11614 #[cfg(feature = "presence")]
11615 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11616 #[cfg(feature = "ws")]
11617 shutdown: tokio_util::sync::CancellationToken::new(),
11618 policy_registry: crate::authorization::PolicyRegistry::default(),
11619 forbidden_response: crate::authorization::ForbiddenResponse::default(),
11620 auth_session_key: "user_id".to_owned(),
11621 shared_cache: None,
11622 clock: std::sync::Arc::new(crate::time::SystemClock),
11623 app_id: AppState::next_app_id(),
11624 };
11625 let router = crate::router::build_router_with_static(
11626 vec![test_get_route("/other", "other_page")],
11627 &config,
11628 state,
11629 Some(dist.as_path()),
11630 );
11631
11632 let response = router
11635 .oneshot(
11636 Request::builder()
11637 .uri("/docs/")
11638 .body(Body::empty())
11639 .unwrap(),
11640 )
11641 .await
11642 .unwrap();
11643
11644 assert_eq!(response.status(), StatusCode::OK);
11645 let csp = response
11646 .headers()
11647 .get("content-security-policy")
11648 .expect("static-first HTML should still receive security headers")
11649 .to_str()
11650 .unwrap();
11651 assert!(csp.contains("script-src 'self'"), "csp = {csp}");
11652 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11653 .await
11654 .unwrap();
11655 assert_eq!(std::str::from_utf8(&body).unwrap(), "<h1>Static Docs</h1>");
11656 }
11657
11658 #[tokio::test]
11659 async fn build_mode_static_rendering_bypasses_startup_barrier() {
11660 temp_env::async_with_vars([("AUTUMN_BUILD_STATIC", Some("1"))], async {
11661 let config = AutumnConfig::default();
11662 let state = AppState::for_test().with_startup_complete(false);
11663 let router = crate::router::build_router(
11664 vec![Route {
11665 method: http::Method::GET,
11666 path: "/about",
11667 handler: axum::routing::get(|| async { "About Page Content" }),
11668 name: "about",
11669 api_doc: crate::openapi::ApiDoc {
11670 method: "GET",
11671 path: "/about",
11672 operation_id: "about",
11673 success_status: 200,
11674 ..Default::default()
11675 },
11676 repository: None,
11677 idempotency: crate::route::RouteIdempotency::Direct,
11678 timeout: crate::route::RouteTimeout::Inherit,
11679 api_version: None,
11680 sunset_opt_out: false,
11681 }],
11682 &config,
11683 state,
11684 );
11685 let tmp = tempfile::tempdir().unwrap();
11686 let dist = tmp.path().join("dist");
11687
11688 let result = crate::static_gen::render_static_routes(
11689 router,
11690 &[crate::static_gen::StaticRouteMeta {
11691 path: "/about",
11692 name: "about",
11693 revalidate: None,
11694 params_fn: None,
11695 }],
11696 &dist,
11697 )
11698 .await;
11699
11700 assert!(result.is_ok(), "build failed: {:?}", result.err());
11701 let html = std::fs::read_to_string(dist.join("about/index.html")).unwrap();
11702 assert_eq!(html, "About Page Content");
11703 })
11704 .await;
11705 }
11706
11707 #[tokio::test]
11708 async fn build_router_injects_live_reload_script_when_enabled() {
11709 let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11710 std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11711 temp_env::async_with_vars(
11712 [
11713 ("AUTUMN_DEV_RELOAD", Some("1")),
11714 (
11715 "AUTUMN_DEV_RELOAD_STATE",
11716 Some(reload_file.path().to_str().expect("utf-8 path")),
11717 ),
11718 ],
11719 async {
11720 let router = test_router(vec![Route {
11721 method: http::Method::GET,
11722 path: "/page",
11723 handler: axum::routing::get(|| async {
11724 axum::response::Html("<html><body><main>ok</main></body></html>")
11725 }),
11726 name: "page",
11727 api_doc: crate::openapi::ApiDoc {
11728 method: "GET",
11729 path: "/page",
11730 operation_id: "page",
11731 success_status: 200,
11732 ..Default::default()
11733 },
11734 repository: None,
11735 idempotency: crate::route::RouteIdempotency::Direct,
11736 timeout: crate::route::RouteTimeout::Inherit,
11737 api_version: None,
11738 sunset_opt_out: false,
11739 }]);
11740
11741 let response = router
11742 .oneshot(Request::builder().uri("/page").body(Body::empty()).unwrap())
11743 .await
11744 .unwrap();
11745
11746 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11747 .await
11748 .unwrap();
11749 let html = std::str::from_utf8(&body).expect("utf-8");
11750 assert!(html.contains("/__autumn/live-reload"));
11751 },
11752 )
11753 .await;
11754 }
11755
11756 #[tokio::test]
11757 async fn build_router_mounts_dev_reload_script_endpoint_when_enabled() {
11758 let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11763 std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11764 temp_env::async_with_vars(
11765 [
11766 ("AUTUMN_DEV_RELOAD", Some("1")),
11767 (
11768 "AUTUMN_DEV_RELOAD_STATE",
11769 Some(reload_file.path().to_str().expect("utf-8 path")),
11770 ),
11771 ],
11772 async {
11773 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11774
11775 let response = router
11776 .oneshot(
11777 Request::builder()
11778 .uri("/__autumn/live-reload.js")
11779 .body(Body::empty())
11780 .unwrap(),
11781 )
11782 .await
11783 .unwrap();
11784
11785 assert_eq!(response.status(), StatusCode::OK);
11786 assert_eq!(
11787 response
11788 .headers()
11789 .get("content-type")
11790 .and_then(|v| v.to_str().ok()),
11791 Some("application/javascript; charset=utf-8")
11792 );
11793 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11794 .await
11795 .unwrap();
11796 let js = std::str::from_utf8(&body).expect("utf-8");
11797 assert!(js.contains("fetch("), "js body: {js}");
11798 },
11799 )
11800 .await;
11801 }
11802
11803 #[tokio::test]
11804 async fn build_router_mounts_dev_reload_endpoint_when_enabled() {
11805 let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11806 std::fs::write(reload_file.path(), r#"{"version":7,"kind":"css"}"#).expect("write");
11807 temp_env::async_with_vars(
11808 [
11809 ("AUTUMN_DEV_RELOAD", Some("1")),
11810 (
11811 "AUTUMN_DEV_RELOAD_STATE",
11812 Some(reload_file.path().to_str().expect("utf-8 path")),
11813 ),
11814 ],
11815 async {
11816 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11817
11818 let response = router
11819 .oneshot(
11820 Request::builder()
11821 .uri("/__autumn/live-reload")
11822 .body(Body::empty())
11823 .unwrap(),
11824 )
11825 .await
11826 .unwrap();
11827
11828 assert_eq!(response.status(), StatusCode::OK);
11829 assert_eq!(
11830 response.headers().get("cache-control").unwrap(),
11831 "no-store, no-cache, must-revalidate"
11832 );
11833 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11834 .await
11835 .unwrap();
11836 assert_eq!(&body[..], br#"{"version":7,"kind":"css"}"#);
11837 },
11838 )
11839 .await;
11840 }
11841
11842 #[tokio::test]
11843 async fn build_router_disables_cache_for_static_assets_in_dev_reload_mode() {
11844 let project = tempfile::tempdir().expect("project dir");
11845 let static_dir = project.path().join("static");
11846 std::fs::create_dir_all(&static_dir).expect("mkdir");
11847 std::fs::write(static_dir.join("demo.txt"), "hello").expect("write static file");
11848 let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11849 std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11850 temp_env::async_with_vars(
11851 [
11852 (
11853 "AUTUMN_MANIFEST_DIR",
11854 Some(project.path().to_str().expect("utf-8 path")),
11855 ),
11856 ("AUTUMN_DEV_RELOAD", Some("1")),
11857 (
11858 "AUTUMN_DEV_RELOAD_STATE",
11859 Some(reload_file.path().to_str().expect("utf-8 path")),
11860 ),
11861 ],
11862 async {
11863 let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11864
11865 let response = router
11866 .oneshot(
11867 Request::builder()
11868 .uri("/static/demo.txt")
11869 .body(Body::empty())
11870 .unwrap(),
11871 )
11872 .await
11873 .unwrap();
11874
11875 assert_eq!(response.status(), StatusCode::OK);
11876 assert_eq!(
11877 response.headers().get("cache-control").unwrap(),
11878 "no-store, no-cache, must-revalidate"
11879 );
11880 },
11881 )
11882 .await;
11883 }
11884
11885 #[test]
11886 fn app_builder_accepts_static_routes() {
11887 use crate::static_gen::StaticRouteMeta;
11888 let metas = vec![StaticRouteMeta {
11889 path: "/about",
11890 name: "about",
11891 revalidate: None,
11892 params_fn: None,
11893 }];
11894 let builder = app().static_routes(metas);
11895 assert_eq!(builder.static_metas.len(), 1);
11896 }
11897
11898 #[test]
11899 fn project_dir_defaults_to_subdir() {
11900 let env = crate::config::MockEnv::new();
11903 let dir = super::project_dir("dist", &env);
11904 assert_eq!(dir, std::path::PathBuf::from("dist"));
11905 }
11906
11907 pub fn test_router_with_config(routes: Vec<Route>, config: &AutumnConfig) -> axum::Router {
11909 let state = AppState {
11910 extensions: std::sync::Arc::new(std::sync::RwLock::new(
11911 std::collections::HashMap::new(),
11912 )),
11913 #[cfg(feature = "db")]
11914 pool: None,
11915 #[cfg(feature = "db")]
11916 replica_pool: None,
11917 #[cfg(feature = "db")]
11918 shards: None,
11919 profile: None,
11920 role: crate::config::ProcessRole::Combined,
11921 started_at: std::time::Instant::now(),
11922 health_detailed: true,
11923 probes: crate::probe::ProbeState::ready_for_test(),
11924 metrics: crate::middleware::MetricsCollector::new(),
11925 log_levels: crate::actuator::LogLevels::new("info"),
11926 task_registry: crate::actuator::TaskRegistry::new(),
11927 job_registry: crate::actuator::JobRegistry::new(),
11928 config_props: crate::actuator::ConfigProperties::default(),
11929 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11930 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11931 #[cfg(feature = "ws")]
11932 channels: crate::channels::Channels::new(32),
11933 #[cfg(feature = "presence")]
11934 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11935 #[cfg(feature = "ws")]
11936 shutdown: tokio_util::sync::CancellationToken::new(),
11937 policy_registry: crate::authorization::PolicyRegistry::default(),
11938 forbidden_response: crate::authorization::ForbiddenResponse::default(),
11939 auth_session_key: "user_id".to_owned(),
11940 shared_cache: None,
11941 clock: std::sync::Arc::new(crate::time::SystemClock),
11942 app_id: AppState::next_app_id(),
11943 };
11944 crate::router::build_router(routes, config, state)
11945 }
11946
11947 #[tokio::test]
11948 async fn cors_wildcard_allows_any_origin() {
11949 let mut config = AutumnConfig::default();
11950 config.cors.allowed_origins = vec!["*".to_owned()];
11951 let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
11952
11953 let response = router
11954 .oneshot(
11955 Request::builder()
11956 .uri("/test")
11957 .header("Origin", "https://example.com")
11958 .body(Body::empty())
11959 .unwrap(),
11960 )
11961 .await
11962 .unwrap();
11963
11964 assert_eq!(response.status(), StatusCode::OK);
11965 assert_eq!(
11966 response
11967 .headers()
11968 .get("access-control-allow-origin")
11969 .unwrap(),
11970 "*"
11971 );
11972 }
11973
11974 #[tokio::test]
11975 async fn cors_specific_origin_reflected() {
11976 let mut config = AutumnConfig::default();
11977 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
11978 let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
11979
11980 let response = router
11981 .oneshot(
11982 Request::builder()
11983 .uri("/test")
11984 .header("Origin", "https://example.com")
11985 .body(Body::empty())
11986 .unwrap(),
11987 )
11988 .await
11989 .unwrap();
11990
11991 assert_eq!(response.status(), StatusCode::OK);
11992 assert_eq!(
11993 response
11994 .headers()
11995 .get("access-control-allow-origin")
11996 .unwrap(),
11997 "https://example.com"
11998 );
11999 }
12000
12001 #[tokio::test]
12002 async fn cors_disabled_when_no_origins() {
12003 let config = AutumnConfig::default();
12004 assert!(config.cors.allowed_origins.is_empty());
12005 let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
12006
12007 let response = router
12008 .oneshot(
12009 Request::builder()
12010 .uri("/test")
12011 .header("Origin", "https://example.com")
12012 .body(Body::empty())
12013 .unwrap(),
12014 )
12015 .await
12016 .unwrap();
12017
12018 assert_eq!(response.status(), StatusCode::OK);
12019 assert!(
12020 response
12021 .headers()
12022 .get("access-control-allow-origin")
12023 .is_none()
12024 );
12025 }
12026
12027 #[tokio::test]
12028 async fn cors_preflight_returns_204() {
12029 let mut config = AutumnConfig::default();
12030 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
12031 let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
12032
12033 let response = router
12034 .oneshot(
12035 Request::builder()
12036 .method("OPTIONS")
12037 .uri("/test")
12038 .header("Origin", "https://example.com")
12039 .header("Access-Control-Request-Method", "GET")
12040 .body(Body::empty())
12041 .unwrap(),
12042 )
12043 .await
12044 .unwrap();
12045
12046 assert_eq!(response.status(), StatusCode::OK);
12047 assert!(
12048 response
12049 .headers()
12050 .contains_key("access-control-allow-methods")
12051 );
12052 }
12053
12054 #[tokio::test]
12055 async fn build_router_with_static_skips_without_manifest() {
12056 let tmp = tempfile::tempdir().expect("tempdir");
12059 let dist = tmp.path().join("dist");
12060 std::fs::create_dir_all(&dist).expect("mkdir");
12061 let config = AutumnConfig::default();
12064 let state = AppState {
12065 extensions: std::sync::Arc::new(std::sync::RwLock::new(
12066 std::collections::HashMap::new(),
12067 )),
12068 #[cfg(feature = "db")]
12069 pool: None,
12070 #[cfg(feature = "db")]
12071 replica_pool: None,
12072 #[cfg(feature = "db")]
12073 shards: None,
12074 profile: None,
12075 role: crate::config::ProcessRole::Combined,
12076 started_at: std::time::Instant::now(),
12077 health_detailed: true,
12078 probes: crate::probe::ProbeState::ready_for_test(),
12079 metrics: crate::middleware::MetricsCollector::new(),
12080 log_levels: crate::actuator::LogLevels::new("info"),
12081 task_registry: crate::actuator::TaskRegistry::new(),
12082 job_registry: crate::actuator::JobRegistry::new(),
12083 config_props: crate::actuator::ConfigProperties::default(),
12084 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12085 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12086 #[cfg(feature = "ws")]
12087 channels: crate::channels::Channels::new(32),
12088 #[cfg(feature = "presence")]
12089 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12090 #[cfg(feature = "ws")]
12091 shutdown: tokio_util::sync::CancellationToken::new(),
12092 policy_registry: crate::authorization::PolicyRegistry::default(),
12093 forbidden_response: crate::authorization::ForbiddenResponse::default(),
12094 auth_session_key: "user_id".to_owned(),
12095 shared_cache: None,
12096 clock: std::sync::Arc::new(crate::time::SystemClock),
12097 app_id: AppState::next_app_id(),
12098 };
12099 let router = crate::router::build_router_with_static(
12100 vec![test_get_route("/test", "test")],
12101 &config,
12102 state,
12103 Some(dist.as_path()),
12104 );
12105
12106 let response = router
12107 .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
12108 .await
12109 .unwrap();
12110 assert_eq!(response.status(), StatusCode::OK);
12111 }
12112
12113 #[tokio::test]
12114 async fn build_router_with_static_none_dist() {
12115 let config = AutumnConfig::default();
12117 let state = AppState {
12118 extensions: std::sync::Arc::new(std::sync::RwLock::new(
12119 std::collections::HashMap::new(),
12120 )),
12121 #[cfg(feature = "db")]
12122 pool: None,
12123 #[cfg(feature = "db")]
12124 replica_pool: None,
12125 #[cfg(feature = "db")]
12126 shards: None,
12127 profile: None,
12128 role: crate::config::ProcessRole::Combined,
12129 started_at: std::time::Instant::now(),
12130 health_detailed: true,
12131 probes: crate::probe::ProbeState::ready_for_test(),
12132 metrics: crate::middleware::MetricsCollector::new(),
12133 log_levels: crate::actuator::LogLevels::new("info"),
12134 task_registry: crate::actuator::TaskRegistry::new(),
12135 job_registry: crate::actuator::JobRegistry::new(),
12136 config_props: crate::actuator::ConfigProperties::default(),
12137 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12138 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12139 #[cfg(feature = "ws")]
12140 channels: crate::channels::Channels::new(32),
12141 #[cfg(feature = "presence")]
12142 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12143 #[cfg(feature = "ws")]
12144 shutdown: tokio_util::sync::CancellationToken::new(),
12145 policy_registry: crate::authorization::PolicyRegistry::default(),
12146 forbidden_response: crate::authorization::ForbiddenResponse::default(),
12147 auth_session_key: "user_id".to_owned(),
12148 shared_cache: None,
12149 clock: std::sync::Arc::new(crate::time::SystemClock),
12150 app_id: AppState::next_app_id(),
12151 };
12152 let router = crate::router::build_router_with_static(
12153 vec![test_get_route("/test", "test")],
12154 &config,
12155 state,
12156 None,
12157 );
12158
12159 let response = router
12160 .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
12161 .await
12162 .unwrap();
12163 assert_eq!(response.status(), StatusCode::OK);
12164 }
12165
12166 #[test]
12169 fn format_route_lines_lists_user_routes() {
12170 let routes = vec![
12171 test_get_route("/", "index"),
12172 test_get_route("/users/{id}", "get_user"),
12173 ];
12174 let config = AutumnConfig::default();
12175 let output = format_route_lines(&routes, &[], &config);
12176 assert!(output.contains("-> index"));
12177 assert!(output.contains("/ GET"));
12178 assert!(output.contains("/users/{id}"));
12179 assert!(output.contains("-> get_user"));
12180 }
12181
12182 #[test]
12183 fn config_runtime_drift_format_route_lines_uses_actuator_prefix() {
12184 let mut config = AutumnConfig::default();
12185 config.actuator.prefix = "/ops".to_owned();
12186 let output = format_route_lines(&[], &[], &config);
12187 assert!(output.contains("-> health"));
12188 assert!(output.contains("/ops/*"));
12189 }
12190
12191 #[test]
12192 fn format_task_lines_none_when_empty() {
12193 assert!(format_task_lines(&[]).is_none());
12194 }
12195
12196 #[test]
12197 fn format_task_lines_fixed_delay() {
12198 let tasks = vec![crate::task::TaskInfo {
12199 name: "cleanup".into(),
12200 schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_secs(300)),
12201 coordination: crate::task::TaskCoordination::Fleet,
12202 handler: |_| Box::pin(async { Ok(()) }),
12203 }];
12204 let output = format_task_lines(&tasks).unwrap();
12205 assert!(output.contains("cleanup (every 300s)"));
12206 }
12207
12208 #[test]
12209 fn format_task_lines_cron() {
12210 let tasks = vec![crate::task::TaskInfo {
12211 name: "nightly".into(),
12212 schedule: crate::task::Schedule::Cron {
12213 expression: "0 0 * * *".into(),
12214 timezone: None,
12215 },
12216 coordination: crate::task::TaskCoordination::Fleet,
12217 handler: |_| Box::pin(async { Ok(()) }),
12218 }];
12219 let output = format_task_lines(&tasks).unwrap();
12220 assert!(output.contains("nightly (cron 0 0 * * *)"));
12221 }
12222
12223 #[test]
12224 fn format_middleware_list_default() {
12225 let config = AutumnConfig::default();
12226 let output = format_middleware_list(&config);
12227 assert!(output.contains("RequestId"));
12228 assert!(output.contains("SecurityHeaders"));
12229 assert!(output.contains("Session (in-memory)"));
12230 assert!(output.contains("Metrics"));
12231 assert!(!output.contains("CORS"));
12233 assert!(!output.contains("CSRF"));
12234 }
12235
12236 #[test]
12237 fn format_middleware_list_with_cors_and_csrf() {
12238 let config = AutumnConfig {
12239 cors: crate::config::CorsConfig {
12240 allowed_origins: vec!["https://example.com".into()],
12241 ..crate::config::CorsConfig::default()
12242 },
12243 security: crate::security::config::SecurityConfig {
12244 csrf: crate::security::config::CsrfConfig {
12245 enabled: true,
12246 ..crate::security::config::CsrfConfig::default()
12247 },
12248 ..crate::security::config::SecurityConfig::default()
12249 },
12250 ..AutumnConfig::default()
12251 };
12252 let output = format_middleware_list(&config);
12253 assert!(output.contains("CORS"));
12254 assert!(output.contains("CSRF"));
12255 }
12256
12257 #[test]
12258 fn mask_database_url_with_password() {
12259 let masked = mask_database_url("postgres://user:secret@localhost:5432/mydb", 10);
12260 assert!(masked.contains("****"));
12261 assert!(!masked.contains("secret"));
12262 assert!(masked.contains("postgres://user:****@localhost:5432/mydb"));
12263 assert!(masked.contains("pool_size=10"));
12264 }
12265
12266 #[test]
12267 fn mask_database_url_without_password() {
12268 let masked = mask_database_url("postgres://localhost/mydb", 5);
12269 assert!(!masked.contains("****"));
12270 assert!(masked.contains("postgres://localhost/mydb"));
12271 assert!(masked.contains("pool_size=5"));
12272 }
12273
12274 #[test]
12275 fn mask_database_url_edge_cases() {
12276 let masked2 = mask_database_url("postgres://user:p%40ssw%3Ard%21@localhost:5432/mydb", 10);
12283 assert!(masked2.contains("****"));
12284 assert!(!masked2.contains("p%40ssw%3Ard%21"));
12285 assert!(masked2.contains("postgres://user:****@localhost:5432/mydb"));
12286
12287 let masked3 = mask_database_url("postgres://:secret@localhost:5432/mydb", 10);
12289 assert!(masked3.contains("****"));
12290 assert!(!masked3.contains("secret"));
12291 assert!(masked3.contains("postgres://:****@localhost:5432/mydb"));
12292 }
12293 #[test]
12294 fn mask_database_url_invalid_url_fallback() {
12295 let masked = mask_database_url("this is completely invalid as a URL with supersecret", 10);
12296 assert!(masked.contains("****"));
12297 assert!(!masked.contains("supersecret"));
12298 assert!(masked.contains("pool_size=10"));
12299 }
12300
12301 #[test]
12302 fn format_config_summary_defaults() {
12303 let config = AutumnConfig::default();
12304 let output = format_config_summary(&config);
12305 assert!(output.contains("profile: none"));
12306 assert!(output.contains("server: 127.0.0.1:3000"));
12307 assert!(output.contains("database: not configured"));
12308 assert!(output.contains("log_level:"));
12309 assert!(output.contains("telemetry: disabled"));
12310 assert!(output.contains("health: /health"));
12311 }
12312
12313 #[test]
12314 fn format_config_summary_with_db() {
12315 let config = AutumnConfig {
12316 database: crate::config::DatabaseConfig {
12317 url: Some("postgres://user:pass@host/db".into()),
12318 pool_size: 20,
12319 ..crate::config::DatabaseConfig::default()
12320 },
12321 ..AutumnConfig::default()
12322 };
12323 let output = format_config_summary(&config);
12324 assert!(output.contains("user:****@host/db"));
12325 assert!(output.contains("pool_size=20"));
12326 assert!(!output.contains("pass"));
12327 }
12328
12329 #[test]
12330 fn format_config_summary_with_profile() {
12331 let config = AutumnConfig {
12332 profile: Some("prod".into()),
12333 ..AutumnConfig::default()
12334 };
12335 let output = format_config_summary(&config);
12336 assert!(output.contains("profile: prod"));
12337 }
12338
12339 #[test]
12340 fn format_config_summary_with_telemetry() {
12341 let config = AutumnConfig {
12342 telemetry: crate::config::TelemetryConfig {
12343 enabled: true,
12344 service_name: "orders-api".into(),
12345 otlp_endpoint: Some("http://otel-collector:4317".into()),
12346 ..crate::config::TelemetryConfig::default()
12347 },
12348 ..AutumnConfig::default()
12349 };
12350 let output = format_config_summary(&config);
12351 assert!(output.contains("telemetry: Grpc -> http://otel-collector:4317"));
12352 }
12353
12354 #[test]
12355 fn log_startup_transparency_runs_without_panic() {
12356 let routes = vec![test_get_route("/", "index")];
12360 let tasks = vec![crate::task::TaskInfo {
12361 name: "cleanup".into(),
12362 schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_secs(60)),
12363 coordination: crate::task::TaskCoordination::Fleet,
12364 handler: |_| Box::pin(async { Ok(()) }),
12365 }];
12366 let config = AutumnConfig::default();
12367 log_startup_transparency(&routes, &tasks, &[], &config);
12368 }
12369
12370 #[test]
12371 fn log_startup_transparency_no_tasks() {
12372 let routes = vec![test_get_route("/health", "check")];
12373 let config = AutumnConfig::default();
12374 log_startup_transparency(&routes, &[], &[], &config);
12375 }
12376
12377 #[cfg(feature = "ws")]
12378 #[tokio::test]
12379 async fn start_task_scheduler_broadcasts_events() {
12380 let state = AppState {
12381 extensions: std::sync::Arc::new(std::sync::RwLock::new(
12382 std::collections::HashMap::new(),
12383 )),
12384 #[cfg(feature = "db")]
12385 pool: None,
12386 #[cfg(feature = "db")]
12387 replica_pool: None,
12388 #[cfg(feature = "db")]
12389 shards: None,
12390 profile: None,
12391 role: crate::config::ProcessRole::Combined,
12392 started_at: std::time::Instant::now(),
12393 health_detailed: true,
12394 probes: crate::probe::ProbeState::ready_for_test(),
12395 metrics: crate::middleware::MetricsCollector::new(),
12396 log_levels: crate::actuator::LogLevels::new("info"),
12397 task_registry: crate::actuator::TaskRegistry::new(),
12398 job_registry: crate::actuator::JobRegistry::new(),
12399 config_props: crate::actuator::ConfigProperties::default(),
12400 channels: crate::channels::Channels::new(32),
12401 #[cfg(feature = "presence")]
12402 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12403 shutdown: tokio_util::sync::CancellationToken::new(),
12404 policy_registry: crate::authorization::PolicyRegistry::default(),
12405 forbidden_response: crate::authorization::ForbiddenResponse::default(),
12406 auth_session_key: "user_id".to_owned(),
12407 shared_cache: None,
12408 clock: std::sync::Arc::new(crate::time::SystemClock),
12409 app_id: AppState::next_app_id(),
12410 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12411 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12412 };
12413
12414 let mut rx = state.channels().subscribe("sys:tasks");
12415
12416 let task = crate::task::TaskInfo {
12417 name: "test_broadcaster".into(),
12418 schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_millis(1)),
12420 coordination: crate::task::TaskCoordination::Fleet,
12421 handler: |_| Box::pin(async { Ok(()) }),
12422 };
12423
12424 let state_clone = state.clone();
12426 tokio::spawn(async move {
12427 super::start_task_scheduler(
12428 vec![task],
12429 &state_clone,
12430 &tokio_util::sync::CancellationToken::new(),
12431 );
12432 });
12433
12434 let msg1 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12436 .await
12437 .expect("timeout waiting for start event")
12438 .expect("channel closed");
12439 let json1: serde_json::Value = serde_json::from_str(msg1.as_str()).unwrap();
12440 assert_eq!(json1["event"], "started");
12441 assert_eq!(json1["task"], "test_broadcaster");
12442
12443 let msg2 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12445 .await
12446 .expect("timeout waiting for success event")
12447 .expect("channel closed");
12448 let json2: serde_json::Value = serde_json::from_str(msg2.as_str()).unwrap();
12449 assert_eq!(json2["event"], "success");
12450 assert_eq!(json2["task"], "test_broadcaster");
12451 assert!(json2.get("duration_ms").is_some());
12452 }
12453
12454 #[cfg(feature = "ws")]
12455 #[tokio::test]
12456 async fn start_task_scheduler_broadcasts_failure_events() {
12457 let state = AppState {
12458 extensions: std::sync::Arc::new(std::sync::RwLock::new(
12459 std::collections::HashMap::new(),
12460 )),
12461 #[cfg(feature = "db")]
12462 pool: None,
12463 #[cfg(feature = "db")]
12464 replica_pool: None,
12465 #[cfg(feature = "db")]
12466 shards: None,
12467 profile: None,
12468 role: crate::config::ProcessRole::Combined,
12469 started_at: std::time::Instant::now(),
12470 health_detailed: true,
12471 probes: crate::probe::ProbeState::ready_for_test(),
12472 metrics: crate::middleware::MetricsCollector::new(),
12473 log_levels: crate::actuator::LogLevels::new("info"),
12474 task_registry: crate::actuator::TaskRegistry::new(),
12475 job_registry: crate::actuator::JobRegistry::new(),
12476 config_props: crate::actuator::ConfigProperties::default(),
12477 channels: crate::channels::Channels::new(32),
12478 #[cfg(feature = "presence")]
12479 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12480 shutdown: tokio_util::sync::CancellationToken::new(),
12481 policy_registry: crate::authorization::PolicyRegistry::default(),
12482 forbidden_response: crate::authorization::ForbiddenResponse::default(),
12483 auth_session_key: "user_id".to_owned(),
12484 shared_cache: None,
12485 clock: std::sync::Arc::new(crate::time::SystemClock),
12486 app_id: AppState::next_app_id(),
12487 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12488 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12489 };
12490
12491 let mut rx = state.channels().subscribe("sys:tasks");
12492
12493 let task = crate::task::TaskInfo {
12494 name: "test_failing_task".into(),
12495 schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_millis(1)),
12496 coordination: crate::task::TaskCoordination::Fleet,
12497 handler: |_| {
12498 Box::pin(async { Err(crate::AutumnError::bad_request_msg("forced error")) })
12499 },
12500 };
12501
12502 let state_clone = state.clone();
12503 tokio::spawn(async move {
12504 super::start_task_scheduler(
12505 vec![task],
12506 &state_clone,
12507 &tokio_util::sync::CancellationToken::new(),
12508 );
12509 });
12510
12511 let _ = rx.recv().await.unwrap();
12513
12514 let msg2 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12516 .await
12517 .expect("timeout waiting for failure event")
12518 .expect("channel closed");
12519 let json2: serde_json::Value = serde_json::from_str(msg2.as_str()).unwrap();
12520 assert_eq!(json2["event"], "failure");
12521 assert_eq!(json2["task"], "test_failing_task");
12522 assert_eq!(json2["error"], "forced error");
12523 }
12524
12525 #[tokio::test]
12526 async fn execute_task_result_ok_returns_duration() {
12527 let state = AppState::for_test();
12528 let handler: crate::task::TaskHandler = |_| Box::pin(async { Ok(()) });
12529 let start = std::time::Instant::now();
12530 let result =
12531 super::execute_task_result(&state, handler, start, "test_task", "fixed_delay").await;
12532 assert!(result.is_ok(), "expected Ok from successful handler");
12533 assert!(result.unwrap() < u64::MAX);
12535 }
12536
12537 #[tokio::test]
12538 async fn execute_task_result_err_returns_duration_and_message() {
12539 let state = AppState::for_test();
12540 let handler: crate::task::TaskHandler =
12541 |_| Box::pin(async { Err(crate::AutumnError::bad_request_msg("test error")) });
12542 let start = std::time::Instant::now();
12543 let result =
12544 super::execute_task_result(&state, handler, start, "test_task", "fixed_delay").await;
12545 assert!(result.is_err(), "expected Err from failing handler");
12546 let (duration_ms, msg) = result.unwrap_err();
12547 assert!(duration_ms < u64::MAX);
12548 assert!(msg.contains("test error"));
12549 }
12550
12551 fn instantly_panicking_scheduled_handler(
12552 _state: AppState,
12553 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send>> {
12554 panic!("panic before scheduled future")
12555 }
12556
12557 #[tokio::test]
12558 async fn execute_task_result_reports_immediate_handler_panics() {
12559 let state = AppState::for_test();
12560 let start = std::time::Instant::now();
12561 let result = super::execute_task_result(
12562 &state,
12563 instantly_panicking_scheduled_handler,
12564 start,
12565 "test_task",
12566 "fixed_delay",
12567 )
12568 .await;
12569
12570 let (duration_ms, msg) = result.expect_err("expected Err from panicking handler");
12571 assert!(duration_ms < u64::MAX);
12572 assert!(msg.contains("scheduled task handler panicked: panic before scheduled future"));
12573 }
12574
12575 #[tokio::test]
12576 async fn execute_fixed_delay_task_does_not_timeout_in_process_runs() {
12577 let state = AppState::for_test();
12578 state.task_registry.register_scheduled(
12579 "slow_task",
12580 "every 1s",
12581 crate::task::TaskCoordination::Fleet,
12582 "in_process",
12583 "replica-a",
12584 );
12585 let handler: crate::task::TaskHandler = |_| {
12586 Box::pin(async {
12587 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
12588 Ok(())
12589 })
12590 };
12591 let coordinator = std::sync::Arc::new(
12592 crate::scheduler::InProcessSchedulerCoordinator::new("replica-a"),
12593 );
12594
12595 super::execute_fixed_delay_task(
12596 "slow_task".to_owned(),
12597 state.clone(),
12598 handler,
12599 std::time::Duration::from_secs(1),
12600 crate::task::TaskCoordination::Fleet,
12601 coordinator,
12602 std::time::Duration::from_millis(10),
12603 )
12604 .await;
12605
12606 let snapshot = state.task_registry.snapshot();
12607 let status = &snapshot["slow_task"];
12608 assert_eq!(status.status, "idle");
12609 assert_eq!(status.last_result.as_deref(), Some("ok"));
12610 assert_eq!(status.total_runs, 1);
12611 assert_eq!(status.total_failures, 0);
12612 assert!(status.last_error.is_none());
12613 }
12614
12615 static SKIPPED_LEASE_HANDLER_CALLS: AtomicUsize = AtomicUsize::new(0);
12616
12617 struct DenyingSchedulerCoordinator;
12618
12619 impl crate::scheduler::SchedulerCoordinator for DenyingSchedulerCoordinator {
12620 fn backend(&self) -> &'static str {
12621 "postgres"
12622 }
12623
12624 fn replica_id(&self) -> &'static str {
12625 "replica-a"
12626 }
12627
12628 fn try_acquire<'a>(
12629 &'a self,
12630 _task_name: &'a str,
12631 _tick_key: &'a str,
12632 _coordination: crate::task::TaskCoordination,
12633 ) -> crate::scheduler::SchedulerFuture<
12634 'a,
12635 crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
12636 > {
12637 Box::pin(async { Ok(None) })
12638 }
12639 }
12640
12641 struct GrantingSchedulerCoordinator {
12642 backend: &'static str,
12643 tick_keys: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
12644 release_count: Option<std::sync::Arc<AtomicUsize>>,
12645 }
12646
12647 impl crate::scheduler::SchedulerCoordinator for GrantingSchedulerCoordinator {
12648 fn backend(&self) -> &'static str {
12649 self.backend
12650 }
12651
12652 fn replica_id(&self) -> &'static str {
12653 "replica-a"
12654 }
12655
12656 fn try_acquire<'a>(
12657 &'a self,
12658 _task_name: &'a str,
12659 tick_key: &'a str,
12660 _coordination: crate::task::TaskCoordination,
12661 ) -> crate::scheduler::SchedulerFuture<
12662 'a,
12663 crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
12664 > {
12665 Box::pin(async move {
12666 self.tick_keys.lock().unwrap().push(tick_key.to_owned());
12667 let lease = self.release_count.as_ref().map_or_else(
12668 || crate::scheduler::SchedulerLease::local(self.backend, "replica-a"),
12669 |release_count| {
12670 crate::scheduler::SchedulerLease::tracked(
12671 self.backend,
12672 "replica-a",
12673 std::sync::Arc::clone(release_count),
12674 )
12675 },
12676 );
12677 Ok(Some(lease))
12678 })
12679 }
12680 }
12681
12682 fn counted_scheduled_handler(
12683 _state: AppState,
12684 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send>> {
12685 Box::pin(async {
12686 SKIPPED_LEASE_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst);
12687 Ok(())
12688 })
12689 }
12690
12691 #[tokio::test]
12692 async fn execute_fixed_delay_task_skips_handler_when_lease_is_not_acquired() {
12693 SKIPPED_LEASE_HANDLER_CALLS.store(0, Ordering::SeqCst);
12694 let state = AppState::for_test();
12695 state.task_registry.register_scheduled(
12696 "claimed_elsewhere",
12697 "every 1s",
12698 crate::task::TaskCoordination::Fleet,
12699 "postgres",
12700 "replica-a",
12701 );
12702 let coordinator = std::sync::Arc::new(DenyingSchedulerCoordinator);
12703
12704 super::execute_fixed_delay_task(
12705 "claimed_elsewhere".to_owned(),
12706 state.clone(),
12707 counted_scheduled_handler,
12708 std::time::Duration::from_secs(1),
12709 crate::task::TaskCoordination::Fleet,
12710 coordinator,
12711 std::time::Duration::from_secs(1),
12712 )
12713 .await;
12714
12715 let snapshot = state.task_registry.snapshot();
12716 let status = &snapshot["claimed_elsewhere"];
12717 assert_eq!(SKIPPED_LEASE_HANDLER_CALLS.load(Ordering::SeqCst), 0);
12718 assert_eq!(status.total_runs, 0);
12719 assert!(status.current_leader.is_none());
12720 assert!(status.last_tick.is_none());
12721 }
12722
12723 #[tokio::test]
12724 async fn execute_fixed_delay_task_records_distributed_lease_ttl_timeout() {
12725 let state = AppState::for_test();
12726 state.task_registry.register_scheduled(
12727 "slow_distributed_task",
12728 "every 1s",
12729 crate::task::TaskCoordination::Fleet,
12730 "postgres",
12731 "replica-a",
12732 );
12733 let handler: crate::task::TaskHandler = |_| {
12734 Box::pin(async {
12735 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
12736 Ok(())
12737 })
12738 };
12739 let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12740 backend: "postgres",
12741 tick_keys: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
12742 release_count: None,
12743 });
12744
12745 super::execute_fixed_delay_task(
12746 "slow_distributed_task".to_owned(),
12747 state.clone(),
12748 handler,
12749 std::time::Duration::from_secs(1),
12750 crate::task::TaskCoordination::Fleet,
12751 coordinator,
12752 std::time::Duration::from_millis(10),
12753 )
12754 .await;
12755
12756 let snapshot = state.task_registry.snapshot();
12757 let status = &snapshot["slow_distributed_task"];
12758 assert_eq!(status.status, "idle");
12759 assert_eq!(status.last_result.as_deref(), Some("failed"));
12760 assert_eq!(status.total_runs, 1);
12761 assert_eq!(status.total_failures, 1);
12762 assert!(
12763 status
12764 .last_error
12765 .as_deref()
12766 .is_some_and(|error| error.contains("lease TTL"))
12767 );
12768 }
12769
12770 #[tokio::test]
12771 async fn execute_cron_task_uses_scheduled_occurrence_for_tick_key() {
12772 let state = AppState::for_test();
12773 state.task_registry.register_scheduled(
12774 "cron_review_task",
12775 "cron */10 * * * * *",
12776 crate::task::TaskCoordination::Fleet,
12777 "postgres",
12778 "replica-a",
12779 );
12780 let tick_keys = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
12781 let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12782 backend: "postgres",
12783 tick_keys: std::sync::Arc::clone(&tick_keys),
12784 release_count: None,
12785 });
12786 let handler: crate::task::TaskHandler = |_| Box::pin(async { Ok(()) });
12787 let scheduled_unix_secs = 1_700_000_000;
12788
12789 super::execute_cron_task(
12790 "cron_review_task".to_owned(),
12791 state.clone(),
12792 handler,
12793 crate::task::TaskCoordination::Fleet,
12794 coordinator,
12795 std::time::Duration::from_secs(30),
12796 scheduled_unix_secs,
12797 )
12798 .await;
12799
12800 assert_eq!(
12801 tick_keys.lock().unwrap().as_slice(),
12802 ["cron_review_task:1700000000"]
12803 );
12804 }
12805
12806 #[tokio::test]
12807 async fn execute_fixed_delay_task_releases_lease_when_handler_panics() {
12808 let state = AppState::for_test();
12809 state.task_registry.register_scheduled(
12810 "panic_task",
12811 "every 1s",
12812 crate::task::TaskCoordination::Fleet,
12813 "postgres",
12814 "replica-a",
12815 );
12816 let release_count = std::sync::Arc::new(AtomicUsize::new(0));
12817 let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12818 backend: "postgres",
12819 tick_keys: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
12820 release_count: Some(std::sync::Arc::clone(&release_count)),
12821 });
12822 let handler: crate::task::TaskHandler = |_| {
12823 Box::pin(async {
12824 panic!("forced scheduled panic");
12825 #[allow(unreachable_code)]
12826 Ok(())
12827 })
12828 };
12829
12830 super::execute_fixed_delay_task(
12831 "panic_task".to_owned(),
12832 state.clone(),
12833 handler,
12834 std::time::Duration::from_secs(1),
12835 crate::task::TaskCoordination::Fleet,
12836 coordinator,
12837 std::time::Duration::from_secs(30),
12838 )
12839 .await;
12840
12841 let snapshot = state.task_registry.snapshot();
12842 let status = &snapshot["panic_task"];
12843 assert_eq!(release_count.load(Ordering::SeqCst), 1);
12844 assert_eq!(status.status, "idle");
12845 assert_eq!(status.last_result.as_deref(), Some("failed"));
12846 assert_eq!(status.total_runs, 1);
12847 assert_eq!(status.total_failures, 1);
12848 assert!(
12849 status
12850 .last_error
12851 .as_deref()
12852 .is_some_and(|error| error.contains("scheduled task handler panicked"))
12853 );
12854 }
12855
12856 #[test]
12857 fn next_cron_occurrence_skips_overdue_slots() {
12858 use chrono::TimeZone as _;
12859
12860 let cron = "0 * * * * *"
12861 .parse::<croner::Cron>()
12862 .expect("cron expression should parse");
12863 let stale_cursor = chrono_tz::UTC
12864 .with_ymd_and_hms(2026, 5, 5, 12, 0, 0)
12865 .unwrap();
12866 let now = chrono_tz::UTC
12867 .with_ymd_and_hms(2026, 5, 5, 12, 30, 5)
12868 .unwrap();
12869 let next = super::next_cron_occurrence_after(&cron, &stale_cursor, &now)
12870 .expect("next cron occurrence should resolve");
12871
12872 assert_eq!(
12873 next,
12874 chrono_tz::UTC
12875 .with_ymd_and_hms(2026, 5, 5, 12, 31, 0)
12876 .unwrap()
12877 );
12878 }
12879
12880 #[test]
12881 fn cron_occurrence_is_overdue_after_later_slot_passed() {
12882 use chrono::TimeZone as _;
12883
12884 let cron = "0 * * * * *"
12885 .parse::<croner::Cron>()
12886 .expect("cron expression should parse");
12887 let scheduled_at = chrono_tz::UTC
12888 .with_ymd_and_hms(2026, 5, 5, 12, 1, 0)
12889 .unwrap();
12890 let slightly_late = chrono_tz::UTC
12891 .with_ymd_and_hms(2026, 5, 5, 12, 1, 5)
12892 .unwrap();
12893 let after_later_slot = chrono_tz::UTC
12894 .with_ymd_and_hms(2026, 5, 5, 12, 30, 5)
12895 .unwrap();
12896
12897 assert!(
12898 !super::cron_occurrence_is_overdue(&cron, &scheduled_at, &slightly_late)
12899 .expect("overdue check should resolve")
12900 );
12901 assert!(
12902 super::cron_occurrence_is_overdue(&cron, &scheduled_at, &after_later_slot)
12903 .expect("overdue check should resolve")
12904 );
12905 }
12906
12907 #[cfg(feature = "storage")]
12908 mod storage_preflight {
12909 use super::super::{StorageBootstrap, preflight_storage};
12910 use crate::AppState;
12911 use crate::config::AutumnConfig;
12912 use crate::storage::{BlobStoreState, StorageBackend, StorageConfig, StorageLocalConfig};
12913
12914 fn config_with_storage(storage: StorageConfig) -> AutumnConfig {
12915 AutumnConfig {
12916 profile: Some("dev".into()),
12917 storage,
12918 ..AutumnConfig::default()
12919 }
12920 }
12921
12922 #[test]
12923 fn preflight_returns_none_when_disabled() {
12924 let cfg = config_with_storage(StorageConfig {
12925 backend: StorageBackend::Disabled,
12926 ..StorageConfig::default()
12927 });
12928 assert!(preflight_storage(&cfg).is_none());
12929 }
12930
12931 #[test]
12932 fn preflight_provisions_local_backend_against_tempdir() {
12933 let dir = tempfile::tempdir().unwrap();
12934 let cfg = config_with_storage(StorageConfig {
12935 backend: StorageBackend::Local,
12936 local: StorageLocalConfig {
12937 root: dir.path().to_path_buf(),
12938 ..StorageLocalConfig::default()
12939 },
12940 ..StorageConfig::default()
12941 });
12942 let bootstrap = preflight_storage(&cfg).expect("local backend should provision");
12943 assert_eq!(bootstrap.store.provider_id(), "default");
12944 assert!(bootstrap.serving.is_some(), "local backend mounts a route");
12945 }
12946
12947 #[tokio::test]
12948 async fn install_registers_blob_store_on_state() {
12949 let dir = tempfile::tempdir().unwrap();
12950 let cfg = config_with_storage(StorageConfig {
12951 backend: StorageBackend::Local,
12952 local: StorageLocalConfig {
12953 root: dir.path().to_path_buf(),
12954 ..StorageLocalConfig::default()
12955 },
12956 ..StorageConfig::default()
12957 });
12958 let bootstrap: StorageBootstrap = preflight_storage(&cfg).unwrap();
12959
12960 let state = AppState::for_test();
12961 assert!(state.extension::<BlobStoreState>().is_none());
12962 let serving = bootstrap.install(&state);
12963 assert!(serving.is_some());
12964 assert!(state.extension::<BlobStoreState>().is_some());
12965 }
12966
12967 #[test]
12968 fn with_blob_store_stores_custom_store() {
12969 use crate::storage::{
12970 Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream,
12971 };
12972 use bytes::Bytes;
12973 use std::time::Duration;
12974
12975 struct FakeStore;
12976 impl BlobStore for FakeStore {
12977 fn provider_id(&self) -> &'static str {
12978 "fake"
12979 }
12980 fn put<'a>(&'a self, _k: &'a str, _ct: &'a str, _b: Bytes) -> BlobFuture<'a, Blob> {
12981 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12982 }
12983 fn put_stream<'a>(
12984 &'a self,
12985 _k: &'a str,
12986 _ct: &'a str,
12987 _d: ByteStream<'a>,
12988 ) -> BlobFuture<'a, Blob> {
12989 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12990 }
12991 fn get<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Bytes> {
12992 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12993 }
12994 fn delete<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, ()> {
12995 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12996 }
12997 fn head<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
12998 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12999 }
13000 fn presigned_url<'a>(
13001 &'a self,
13002 _k: &'a str,
13003 _e: Duration,
13004 ) -> BlobFuture<'a, String> {
13005 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13006 }
13007 }
13008
13009 let builder = crate::app().with_blob_store(FakeStore);
13010 assert!(builder.blob_store.is_some());
13011 }
13012
13013 #[tokio::test]
13014 async fn with_blob_store_is_installed_on_state() {
13015 use crate::storage::{
13016 Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream,
13017 };
13018 use bytes::Bytes;
13019 use std::time::Duration;
13020
13021 struct FakeStore;
13022 impl BlobStore for FakeStore {
13023 fn provider_id(&self) -> &'static str {
13024 "fake-installed"
13025 }
13026 fn put<'a>(&'a self, _k: &'a str, _ct: &'a str, _b: Bytes) -> BlobFuture<'a, Blob> {
13027 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13028 }
13029 fn put_stream<'a>(
13030 &'a self,
13031 _k: &'a str,
13032 _ct: &'a str,
13033 _d: ByteStream<'a>,
13034 ) -> BlobFuture<'a, Blob> {
13035 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13036 }
13037 fn get<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Bytes> {
13038 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13039 }
13040 fn delete<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, ()> {
13041 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13042 }
13043 fn head<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
13044 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13045 }
13046 fn presigned_url<'a>(
13047 &'a self,
13048 _k: &'a str,
13049 _e: Duration,
13050 ) -> BlobFuture<'a, String> {
13051 Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13052 }
13053 }
13054
13055 let builder = crate::app().with_blob_store(FakeStore);
13056 let bootstrap = builder.blob_store.map(|store| StorageBootstrap {
13057 store,
13058 serving: None,
13059 });
13060 let state = AppState::for_test();
13061 assert!(state.extension::<BlobStoreState>().is_none());
13062 if let Some(b) = bootstrap {
13063 b.install(&state);
13064 }
13065 let installed = state
13066 .extension::<BlobStoreState>()
13067 .expect("store should be installed");
13068 assert_eq!(installed.store().provider_id(), "fake-installed");
13069 }
13070 }
13071
13072 struct TestPlugin {
13076 name: &'static str,
13077 route: Route,
13078 }
13079
13080 impl crate::plugin::Plugin for TestPlugin {
13081 fn name(&self) -> std::borrow::Cow<'static, str> {
13082 std::borrow::Cow::Borrowed(self.name)
13083 }
13084
13085 fn build(self, app: AppBuilder) -> AppBuilder {
13086 app.routes(vec![self.route])
13087 }
13088 }
13089
13090 #[test]
13091 fn routes_registered_before_plugin_are_user_sourced() {
13092 let user_route = test_get_route("/home", "home");
13093 let builder = app().routes(vec![user_route]);
13094 assert_eq!(builder.route_sources.len(), 1);
13095 assert_eq!(
13096 builder.route_sources[0],
13097 crate::route_listing::RouteSource::User
13098 );
13099 }
13100
13101 #[test]
13102 fn routes_registered_inside_plugin_are_plugin_sourced() {
13103 let plugin_route = test_get_route("/plugin-page", "plugin_page");
13104 let plugin = TestPlugin {
13105 name: "my-plugin",
13106 route: plugin_route,
13107 };
13108 let builder = app().plugin(plugin);
13109 assert_eq!(builder.route_sources.len(), 1);
13110 assert_eq!(
13111 builder.route_sources[0],
13112 crate::route_listing::RouteSource::Plugin("my-plugin".to_owned())
13113 );
13114 }
13115
13116 #[test]
13117 fn routes_registered_after_plugin_revert_to_user_sourced() {
13118 let plugin_route = test_get_route("/plugin-page", "plugin_page");
13119 let user_route = test_get_route("/home", "home");
13120 let plugin = TestPlugin {
13121 name: "my-plugin",
13122 route: plugin_route,
13123 };
13124 let builder = app().plugin(plugin).routes(vec![user_route]);
13125 assert_eq!(builder.route_sources.len(), 2);
13126 assert_eq!(
13127 builder.route_sources[0],
13128 crate::route_listing::RouteSource::Plugin("my-plugin".to_owned())
13129 );
13130 assert_eq!(
13131 builder.route_sources[1],
13132 crate::route_listing::RouteSource::User
13133 );
13134 }
13135
13136 struct OuterPlugin;
13138
13139 impl crate::plugin::Plugin for OuterPlugin {
13140 fn name(&self) -> std::borrow::Cow<'static, str> {
13141 "outer".into()
13142 }
13143
13144 fn build(self, app: AppBuilder) -> AppBuilder {
13145 let inner = TestPlugin {
13146 name: "inner",
13147 route: test_get_route("/inner", "inner"),
13148 };
13149 app.plugin(inner)
13150 .routes(vec![test_get_route("/outer-after", "outer_after")])
13151 }
13152 }
13153
13154 #[test]
13155 fn outer_plugin_source_restored_after_nested_plugin() {
13156 let builder = app().plugin(OuterPlugin);
13157 assert_eq!(builder.route_sources.len(), 2);
13159 assert_eq!(
13160 builder.route_sources[0],
13161 crate::route_listing::RouteSource::Plugin("inner".to_owned()),
13162 "first route should be attributed to inner plugin"
13163 );
13164 assert_eq!(
13165 builder.route_sources[1],
13166 crate::route_listing::RouteSource::Plugin("outer".to_owned()),
13167 "second route should be re-attributed to outer plugin after nested build"
13168 );
13169 }
13170
13171 #[tokio::test]
13174 async fn shutdown_hooks_with_timeout_runs_all_fast_hooks() {
13175 use std::sync::atomic::{AtomicUsize, Ordering};
13176 let counter = Arc::new(AtomicUsize::new(0));
13177 let c1 = Arc::clone(&counter);
13178 let c2 = Arc::clone(&counter);
13179
13180 let hooks: Vec<ShutdownHook> = vec![
13181 Box::new(move || {
13182 let c = Arc::clone(&c1);
13183 Box::pin(async move {
13184 c.fetch_add(1, Ordering::SeqCst);
13185 })
13186 }),
13187 Box::new(move || {
13188 let c = Arc::clone(&c2);
13189 Box::pin(async move {
13190 c.fetch_add(1, Ordering::SeqCst);
13191 })
13192 }),
13193 ];
13194
13195 run_shutdown_hooks_with_timeout(
13196 &hooks,
13197 std::time::Duration::from_secs(2),
13198 std::time::Duration::from_secs(10),
13199 )
13200 .await;
13201
13202 assert_eq!(counter.load(Ordering::SeqCst), 2, "both hooks must run");
13203 }
13204
13205 #[tokio::test]
13206 async fn shutdown_hooks_with_timeout_tolerates_slow_hook_overrun() {
13207 use std::sync::atomic::{AtomicBool, Ordering};
13208 let fast_ran = Arc::new(AtomicBool::new(false));
13209 let fr = Arc::clone(&fast_ran);
13210
13211 let hooks: Vec<ShutdownHook> = vec![
13212 Box::new(move || {
13214 let fr = Arc::clone(&fr);
13215 Box::pin(async move {
13216 fr.store(true, Ordering::SeqCst);
13217 })
13218 }),
13219 Box::new(|| {
13221 Box::pin(async move {
13222 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
13223 })
13224 }),
13225 ];
13226
13227 run_shutdown_hooks_with_timeout(
13230 &hooks,
13231 std::time::Duration::from_millis(50),
13232 std::time::Duration::from_secs(1),
13233 )
13234 .await;
13235
13236 assert!(
13237 fast_ran.load(Ordering::SeqCst),
13238 "fast hook must still run even after slow hook overruns its per-hook budget"
13239 );
13240 }
13241
13242 #[cfg(feature = "http-client")]
13245 #[test]
13246 fn build_state_registers_shared_reqwest_client() {
13247 let config = AutumnConfig::default();
13248 let state = build_state(
13249 &config,
13250 #[cfg(feature = "db")]
13251 None,
13252 #[cfg(feature = "db")]
13253 None,
13254 #[cfg(feature = "ws")]
13255 None,
13256 );
13257 assert!(
13258 state
13259 .extension::<crate::http_client::SharedReqwestClient>()
13260 .is_some(),
13261 "build_state must register a SharedReqwestClient for connection-pool sharing"
13262 );
13263 }
13264
13265 #[cfg(feature = "maud")]
13270 #[test]
13271 fn with_story_gallery_installs_story_registry_extension() {
13272 let builder = crate::app().with_story_gallery(crate::stories::StoryGallery::builtin());
13273 let gallery = builder
13274 .story_gallery
13275 .expect("with_story_gallery must store the gallery on the builder");
13276 let expected_count = gallery.stories().len();
13277 assert!(expected_count > 0, "builtin gallery must not be empty");
13278
13279 let config = AutumnConfig::default();
13280 let state = build_state(
13281 &config,
13282 #[cfg(feature = "db")]
13283 None,
13284 #[cfg(feature = "db")]
13285 None,
13286 #[cfg(feature = "ws")]
13287 None,
13288 );
13289 install_story_registry(&state, Some(gallery));
13290 let registry = state
13291 .extension::<crate::stories::StoryRegistry>()
13292 .expect("install_story_registry must publish the StoryRegistry extension");
13293 assert_eq!(
13294 registry.stories().len(),
13295 expected_count,
13296 "every registered story must reach the state extension"
13297 );
13298
13299 let bare_state = build_state(
13302 &config,
13303 #[cfg(feature = "db")]
13304 None,
13305 #[cfg(feature = "db")]
13306 None,
13307 #[cfg(feature = "ws")]
13308 None,
13309 );
13310 install_story_registry(&bare_state, None);
13311 assert!(
13312 bare_state
13313 .extension::<crate::stories::StoryRegistry>()
13314 .is_none(),
13315 "no gallery registered must mean no StoryRegistry extension"
13316 );
13317 }
13318}
13319
13320#[cfg(all(test, unix))]
13321mod unix_socket_tests {
13322 use super::prepare_unix_socket_path;
13323
13324 #[test]
13325 fn prepare_unix_socket_path_noop_when_absent() {
13326 let dir = tempfile::tempdir().expect("tempdir");
13327 let path = dir.path().join("missing.sock");
13328 prepare_unix_socket_path(&path).expect("absent path is fine");
13329 assert!(!path.exists());
13330 }
13331
13332 #[test]
13333 fn prepare_unix_socket_path_removes_stale_socket() {
13334 let dir = tempfile::tempdir().expect("tempdir");
13335 let path = dir.path().join("stale.sock");
13336 let listener = std::os::unix::net::UnixListener::bind(&path).expect("bind socket");
13338 drop(listener);
13339 assert!(path.exists(), "socket file should exist before prepare");
13340 prepare_unix_socket_path(&path).expect("stale socket should be removed");
13341 assert!(!path.exists(), "stale socket should be unlinked");
13342 }
13343
13344 #[test]
13345 fn prepare_unix_socket_path_refuses_live_socket() {
13346 let dir = tempfile::tempdir().expect("tempdir");
13347 let path = dir.path().join("live.sock");
13348 let _listener = std::os::unix::net::UnixListener::bind(&path).expect("bind socket");
13350 let err = prepare_unix_socket_path(&path).expect_err("must refuse a live socket");
13351 assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
13352 assert!(path.exists(), "live socket must not be removed");
13353 }
13354
13355 #[test]
13356 fn prepare_unix_socket_path_errors_on_regular_file() {
13357 let dir = tempfile::tempdir().expect("tempdir");
13358 let path = dir.path().join("not-a-socket");
13359 std::fs::write(&path, b"i am a regular file").expect("write file");
13360 let err = prepare_unix_socket_path(&path).expect_err("must refuse a non-socket file");
13361 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
13362 assert!(path.exists(), "regular file must not be removed");
13363 }
13364}