Skip to main content

autumn_plugin_grpc/
plugin.rs

1//! [`GrpcPlugin`]: the builder and the Autumn `Plugin` implementation.
2
3use std::borrow::Cow;
4use std::collections::HashSet;
5use std::convert::Infallible;
6use std::sync::{Arc, Mutex, OnceLock, PoisonError};
7
8use autumn_web::AppState;
9use autumn_web::app::AppBuilder;
10use autumn_web::plugin::Plugin;
11use autumn_web::route_listing::{RouteClassification, RouteInfo};
12use axum::response::IntoResponse;
13use tonic::body::Body as TonicBody;
14use tonic::server::NamedService;
15use tonic::service::Routes;
16use tower::{Layer, Service};
17
18use crate::config::{ConfigError, DEFAULT_SECTION, GrpcConfig, Listener, Resolved};
19use crate::error::GrpcError;
20use crate::gate::{GrpcGate, SharedOwner};
21use crate::health::GrpcHealthIndicator;
22use crate::lifecycle::LifecycleEvent;
23use crate::metrics::MetricsLayer;
24use crate::registry::{GrpcServers, ServersMetrics};
25use crate::server::{GrpcHandle, Launch, Shared};
26
27/// The name in `Plugin::name`, logs and errors.
28pub const PLUGIN_NAME: &str = "autumn-plugin-grpc";
29
30/// The `autumn-web` series this release is tested with.
31pub const SUPPORTED_AUTUMN_WEB: &str = "0.10";
32
33/// Names of the services the plugin adds itself.
34const HEALTH_SERVICE: &str = "grpc.health.v1.Health";
35const REFLECTION_V1: &str = "grpc.reflection.v1.ServerReflection";
36const REFLECTION_V1ALPHA: &str = "grpc.reflection.v1alpha.ServerReflection";
37
38type Registration = Box<dyn FnOnce(&AppState, Routes) -> Routes + Send>;
39type RouterLayer = Box<dyn FnOnce(axum::Router) -> axum::Router + Send>;
40type Override = Arc<dyn Fn(&mut GrpcConfig) + Send + Sync>;
41
42/// How `autumn routes` classifies user services.
43#[derive(Debug, Clone)]
44enum Posture {
45    /// No declaration. `autumn routes audit` fails on it.
46    Unclassified,
47    /// Declared open with [`GrpcPlugin::public`].
48    Public,
49    /// Behind a guard. The label names it.
50    Gated(String),
51}
52
53/// Work that the startup hook does once.
54struct Pending {
55    registrations: Vec<Registration>,
56    layers: Vec<RouterLayer>,
57    descriptors: Vec<&'static [u8]>,
58}
59
60/// Serves tonic services from an Autumn app, on a dedicated HTTP/2 listener
61/// or on Autumn's HTTP port (`listener = "shared"`).
62///
63/// ```rust,ignore
64/// use autumn_plugin_grpc::GrpcPlugin;
65///
66/// autumn_web::app()
67///     .plugin(
68///         GrpcPlugin::new()
69///             .add_service(GreeterServer::new(MyGreeter))
70///             .file_descriptor_set(greeter::FILE_DESCRIPTOR_SET),
71///     )
72///     .run()
73///     .await;
74/// ```
75///
76/// The plugin reads `[grpc]` from `autumn.toml` (see [`GrpcConfig`]).
77/// Fluent setters apply on top of the file values.
78///
79/// On boot, the plugin:
80///
81/// - builds the services (with `AppState` for
82///   [`add_service_with`](Self::add_service_with)),
83/// - binds the listener (a failure aborts boot),
84/// - serves health and (in `dev`/`test`) reflection,
85/// - adds a `grpc` health indicator and `grpc_server_*` metrics to the
86///   actuator,
87/// - puts a [`GrpcHandle`] into `AppState`.
88///
89/// On shutdown, it drains (see [`GrpcHandle::shutdown`]).
90pub struct GrpcPlugin {
91    section: String,
92    explicit: Option<Box<GrpcConfig>>,
93    overrides: Vec<Override>,
94    development: Option<bool>,
95    resolved: OnceLock<Result<Resolved, ConfigError>>,
96    registrations: Vec<Registration>,
97    service_names: Vec<&'static str>,
98    layers: Vec<RouterLayer>,
99    descriptors: Vec<&'static [u8]>,
100    posture: Posture,
101    shared: Arc<Shared>,
102    #[cfg(feature = "client")]
103    pub(crate) clients: crate::client::Registrations,
104}
105
106impl Default for GrpcPlugin {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl std::fmt::Debug for GrpcPlugin {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("GrpcPlugin")
115            .field("section", &self.section)
116            .field("services", &self.service_names)
117            .finish_non_exhaustive()
118    }
119}
120
121impl GrpcPlugin {
122    /// A plugin with no services, configured from `[grpc]`.
123    #[must_use]
124    pub fn new() -> Self {
125        Self {
126            section: DEFAULT_SECTION.to_owned(),
127            explicit: None,
128            overrides: Vec::new(),
129            development: None,
130            resolved: OnceLock::new(),
131            registrations: Vec::new(),
132            service_names: Vec::new(),
133            layers: Vec::new(),
134            descriptors: Vec::new(),
135            posture: Posture::Unclassified,
136            shared: Arc::new(Shared::new()),
137            #[cfg(feature = "client")]
138            clients: crate::client::Registrations::default(),
139        }
140    }
141
142    /// Serve a tonic service, for example `GreeterServer::new(MyGreeter)`.
143    #[must_use]
144    pub fn add_service<S>(self, service: S) -> Self
145    where
146        S: Service<http::Request<TonicBody>, Error = Infallible>
147            + NamedService
148            + Clone
149            + Send
150            + Sync
151            + 'static,
152        S::Response: IntoResponse,
153        S::Future: Send + 'static,
154    {
155        self.add_service_with(move |_| service)
156    }
157
158    /// Serve a tonic service that the plugin builds at startup, from
159    /// `AppState`. Use this when the service needs the database pool or
160    /// other app state.
161    ///
162    /// ```rust,ignore
163    /// GrpcPlugin::new().add_service_with(|state| {
164    ///     GreeterServer::new(MyGreeter::new(state.clone()))
165    /// })
166    /// ```
167    #[must_use]
168    pub fn add_service_with<S, F>(mut self, build: F) -> Self
169    where
170        F: FnOnce(&AppState) -> S + Send + 'static,
171        S: Service<http::Request<TonicBody>, Error = Infallible>
172            + NamedService
173            + Clone
174            + Send
175            + Sync
176            + 'static,
177        S::Response: IntoResponse,
178        S::Future: Send + 'static,
179    {
180        self.service_names.push(S::NAME);
181        self.registrations.push(Box::new(move |state, routes| {
182            routes.add_service(build(state))
183        }));
184        self
185    }
186
187    /// Add an encoded `FileDescriptorSet` for server reflection. Get it from
188    /// `tonic_prost_build` (`file_descriptor_set_path`) and
189    /// `include_bytes!`.
190    #[must_use]
191    pub fn file_descriptor_set(mut self, encoded: &'static [u8]) -> Self {
192        self.descriptors.push(encoded);
193        self
194    }
195
196    /// Wrap every user service with a tower layer (for example rate limits
197    /// or tracing). The layer does not wrap health or reflection, so probes
198    /// and tools still work. For an auth layer, use [`guard`](Self::guard).
199    ///
200    /// Layers apply in call order: the last layer is the outermost.
201    #[must_use]
202    pub fn layer<L>(mut self, layer: L) -> Self
203    where
204        L: Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
205        L::Service: Service<axum::extract::Request> + Clone + Send + Sync + 'static,
206        <L::Service as Service<axum::extract::Request>>::Response: IntoResponse + 'static,
207        <L::Service as Service<axum::extract::Request>>::Error: Into<Infallible> + 'static,
208        <L::Service as Service<axum::extract::Request>>::Future: Send + 'static,
209    {
210        self.layers
211            .push(Box::new(move |router| router.layer(layer)));
212        self
213    }
214
215    /// Run a tonic interceptor before every user call. Like
216    /// [`layer`](Self::layer), it does not wrap health or reflection. For an
217    /// auth interceptor, use [`guard_interceptor`](Self::guard_interceptor).
218    ///
219    /// ```rust,ignore
220    /// GrpcPlugin::new().interceptor(|mut request: tonic::Request<()>| {
221    ///     request.extensions_mut().insert(RequestStart(Instant::now()));
222    ///     Ok(request)
223    /// })
224    /// ```
225    #[must_use]
226    pub fn interceptor<F>(self, interceptor: F) -> Self
227    where
228        F: tonic::service::Interceptor + Clone + Send + Sync + 'static,
229    {
230        self.layer(tonic::service::InterceptorLayer::new(interceptor))
231    }
232
233    /// Guard every user service with `layer`, for example an auth layer.
234    /// `autumn routes` then shows the services as gated, with `label` as
235    /// the middleware name.
236    #[must_use]
237    pub fn guard<L>(mut self, layer: L, label: impl Into<String>) -> Self
238    where
239        L: Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
240        L::Service: Service<axum::extract::Request> + Clone + Send + Sync + 'static,
241        <L::Service as Service<axum::extract::Request>>::Response: IntoResponse + 'static,
242        <L::Service as Service<axum::extract::Request>>::Error: Into<Infallible> + 'static,
243        <L::Service as Service<axum::extract::Request>>::Future: Send + 'static,
244    {
245        self.posture = Posture::Gated(label.into());
246        self.layer(layer)
247    }
248
249    /// Guard every user service with a tonic interceptor. Like
250    /// [`guard`](Self::guard), with an interceptor in place of a layer.
251    #[must_use]
252    pub fn guard_interceptor<F>(self, interceptor: F, label: impl Into<String>) -> Self
253    where
254        F: tonic::service::Interceptor + Clone + Send + Sync + 'static,
255    {
256        self.guard(tonic::service::InterceptorLayer::new(interceptor), label)
257    }
258
259    /// Declare the user services open to all clients. `autumn routes` then
260    /// shows them as public. Without `guard*` or `public`, the services are
261    /// unclassified, and `autumn routes audit` fails.
262    #[must_use]
263    pub fn public(mut self) -> Self {
264        self.posture = Posture::Public;
265        self
266    }
267
268    /// The gRPC services as `autumn routes` lists them. The method is
269    /// `GRPC` and the path is `/<service>/*`. These routes are on the gRPC
270    /// listener, or on the HTTP port in shared mode. A plugin on a
271    /// non-default section uses the method `GRPC:<section>`. Autumn 0.8
272    /// refuses two plugins that declare the same method and path, and two
273    /// servers can serve one service on different ports.
274    #[must_use]
275    pub fn route_infos(&self) -> Vec<RouteInfo> {
276        let Ok(resolved) = self.resolved() else {
277            return Vec::new();
278        };
279        let config = &resolved.config;
280        if !config.enabled {
281            return Vec::new();
282        }
283        let (classification, middleware) = match &self.posture {
284            Posture::Unclassified => (RouteClassification::Unclassified, None),
285            Posture::Public => (RouteClassification::Public, None),
286            Posture::Gated(label) => (RouteClassification::Gated, Some(label.as_str())),
287        };
288        let method = if self.section == DEFAULT_SECTION {
289            "GRPC".to_owned()
290        } else {
291            format!("GRPC:{}", self.section)
292        };
293        let mut routes: Vec<RouteInfo> = self
294            .service_names
295            .iter()
296            .map(|name| grpc_route(&method, name, classification, middleware))
297            .collect();
298        let mut framework = Vec::new();
299        if config.health {
300            framework.push(HEALTH_SERVICE);
301        }
302        if config.reflection.resolve(resolved.is_development()) {
303            framework.extend([REFLECTION_V1, REFLECTION_V1ALPHA]);
304        }
305        routes.extend(
306            framework
307                .into_iter()
308                .map(|name| grpc_route(&method, name, RouteClassification::Framework, None)),
309        );
310        routes
311    }
312
313    pub(crate) fn reset(&mut self) {
314        self.resolved = OnceLock::new();
315    }
316
317    /// Change the configuration in code, after files and environment.
318    #[must_use]
319    pub fn configure(mut self, apply: impl Fn(&mut GrpcConfig) + Send + Sync + 'static) -> Self {
320        self.overrides.push(Arc::new(apply));
321        self.reset();
322        self
323    }
324
325    /// Use `config` and read no files or environment variables. Fluent
326    /// setters still apply on top.
327    #[must_use]
328    pub fn config(mut self, config: GrpcConfig) -> Self {
329        self.explicit = Some(Box::new(config));
330        self.reset();
331        self
332    }
333
334    /// Read a different section than `[grpc]`. The environment prefix
335    /// changes with the section: `grpc_admin` reads `AUTUMN_GRPC_ADMIN__*`.
336    /// Use this to run two servers.
337    #[must_use]
338    pub fn config_section(mut self, section: impl Into<String>) -> Self {
339        self.section = section.into();
340        self.reset();
341        self
342    }
343
344    /// Force development (`true`) or production (`false`) defaults for
345    /// `auto` settings. By default, the active profile decides.
346    #[must_use]
347    pub fn development(mut self, development: bool) -> Self {
348        self.development = Some(development);
349        self.reset();
350        self
351    }
352
353    /// Choose the listener: [`Listener::Dedicated`] (default) or
354    /// [`Listener::Shared`] (Autumn's HTTP port, feature `multiplex`).
355    #[must_use]
356    pub fn listener(self, listener: Listener) -> Self {
357        self.configure(move |c| c.listener = listener)
358    }
359
360    /// Set the listen address, `IP:port`.
361    #[must_use]
362    pub fn bind(self, addr: impl Into<String>) -> Self {
363        let addr = addr.into();
364        self.configure(move |c| c.bind.clone_from(&addr))
365    }
366
367    /// A handle to this plugin's server. It is valid before and after boot.
368    #[must_use]
369    pub fn handle(&self) -> GrpcHandle {
370        GrpcHandle::new(self.shared.clone())
371    }
372
373    /// Names of the services added with `add_service*`.
374    #[must_use]
375    pub fn service_names(&self) -> &[&'static str] {
376        &self.service_names
377    }
378
379    /// The configuration after files, environment and code.
380    ///
381    /// # Errors
382    ///
383    /// The [`ConfigError`] that aborts boot.
384    pub fn effective_config(&self) -> Result<&GrpcConfig, &ConfigError> {
385        self.resolved().as_ref().map(|r| &r.config)
386    }
387
388    fn resolved(&self) -> &Result<Resolved, ConfigError> {
389        self.resolved.get_or_init(|| self.resolve(None))
390    }
391
392    /// Resolve the config. `env` replaces the process environment (tests).
393    fn resolve(&self, env: Option<&dyn autumn_web::config::Env>) -> Result<Resolved, ConfigError> {
394        #[cfg(feature = "client")]
395        let clients = self.clients.names();
396        #[cfg(not(feature = "client"))]
397        let clients: Vec<&str> = Vec::new();
398        let mut resolved = match (&self.explicit, env) {
399            (Some(config), _) => Resolved::explicit((**config).clone()),
400            (None, Some(env)) => {
401                GrpcConfig::resolve_with_env_and_clients(&self.section, env, &clients)?
402            }
403            (None, None) => GrpcConfig::resolve_with_clients(&self.section, &clients)?,
404        };
405        for apply in &self.overrides {
406            apply(&mut resolved.config);
407        }
408        if let Some(development) = self.development {
409            if development { "dev" } else { "prod" }.clone_into(&mut resolved.profile);
410        }
411        resolved.config.validate()?;
412        Ok(resolved)
413    }
414}
415
416fn grpc_route(
417    method: &str,
418    service: &str,
419    classification: RouteClassification,
420    middleware: Option<&str>,
421) -> RouteInfo {
422    RouteInfo {
423        method: method.to_owned(),
424        path: format!("/{service}/*"),
425        handler: format!("autumn_plugin_grpc::{service}"),
426        classification,
427        middleware: middleware.map(str::to_owned).into_iter().collect(),
428        ..RouteInfo::default()
429    }
430}
431
432/// Largest request the health and reflection services decode. Their
433/// requests are small, and no user layer guards them.
434const PLUGIN_SERVICE_MAX_MESSAGE: usize = 16 * 1024;
435
436/// axum panics on a duplicate route. Return an error first.
437fn check_unique(
438    user_names: &[&'static str],
439    config: &GrpcConfig,
440    development: bool,
441) -> Result<(), GrpcError> {
442    let mut taken: HashSet<&str> = HashSet::new();
443    if config.health {
444        taken.insert(HEALTH_SERVICE);
445    }
446    if config.reflection.resolve(development) {
447        taken.extend([REFLECTION_V1, REFLECTION_V1ALPHA]);
448    }
449    for name in user_names {
450        if !taken.insert(name) {
451            return Err(GrpcError::DuplicateService((*name).to_owned()));
452        }
453    }
454    Ok(())
455}
456
457/// User services, in the user layers. axum applies a layer only to the
458/// routes that exist at the `layer` call. The plugin adds health and
459/// reflection after that call, so the user layers do not wrap them.
460fn user_routes(
461    state: &AppState,
462    registrations: Vec<Registration>,
463    layers: Vec<RouterLayer>,
464) -> Routes {
465    let registered = registrations
466        .into_iter()
467        .fold(Routes::default(), |routes, register| {
468            register(state, routes)
469        });
470    let layered = layers
471        .into_iter()
472        .fold(registered.into_axum_router(), |router, apply| apply(router));
473    Routes::from(layered)
474}
475
476/// Add reflection v1 and v1alpha over the user and health descriptor sets.
477fn add_reflection(
478    routes: Routes,
479    descriptors: &[&'static [u8]],
480    health: bool,
481) -> Result<Routes, GrpcError> {
482    let configure = || {
483        let mut builder = tonic_reflection::server::Builder::configure();
484        if health {
485            builder =
486                builder.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET);
487        }
488        descriptors.iter().fold(builder, |builder, set| {
489            builder.register_encoded_file_descriptor_set(set)
490        })
491    };
492    let invalid = |e: tonic_reflection::server::Error| GrpcError::Reflection(e.to_string());
493    let v1 = configure()
494        .build_v1()
495        .map_err(invalid)?
496        .max_decoding_message_size(PLUGIN_SERVICE_MAX_MESSAGE);
497    let v1alpha = configure()
498        .build_v1alpha()
499        .map_err(invalid)?
500        .max_decoding_message_size(PLUGIN_SERVICE_MAX_MESSAGE);
501    Ok(routes.add_service(v1).add_service(v1alpha))
502}
503
504/// Assemble all routes: user services, then health and reflection, then
505/// the `AppState` and metrics layers around everything.
506fn build_routes(
507    state: &AppState,
508    pending: Pending,
509    config: &GrpcConfig,
510    development: bool,
511    shared: &Arc<Shared>,
512    user_names: &[&'static str],
513) -> Result<(Routes, Option<tonic_health::server::HealthReporter>), GrpcError> {
514    let Pending {
515        registrations,
516        layers,
517        descriptors,
518    } = pending;
519    check_unique(user_names, config, development)?;
520    let mut routes = user_routes(state, registrations, layers);
521    let mut known: HashSet<String> = user_names.iter().map(|n| (*n).to_owned()).collect();
522
523    let reporter = config.health.then(|| {
524        let (reporter, service) = tonic_health::server::health_reporter();
525        let service = service.max_decoding_message_size(PLUGIN_SERVICE_MAX_MESSAGE);
526        routes = std::mem::take(&mut routes).add_service(service);
527        known.insert(HEALTH_SERVICE.to_owned());
528        reporter
529    });
530    if config.reflection.resolve(development) {
531        routes = add_reflection(routes, &descriptors, config.health)?;
532        known.insert(REFLECTION_V1.to_owned());
533        known.insert(REFLECTION_V1ALPHA.to_owned());
534    }
535
536    let mut sets = descriptors.clone();
537    if config.health {
538        sets.push(tonic_health::pb::FILE_DESCRIPTOR_SET);
539    }
540    let mut methods = crate::metrics::methods_from_descriptors(&sets);
541    // The reflection services each have one method.
542    for name in [REFLECTION_V1, REFLECTION_V1ALPHA] {
543        methods.insert(
544            name.to_owned(),
545            HashSet::from(["ServerReflectionInfo".to_owned()]),
546        );
547    }
548    shared
549        .metrics
550        .configure(config.max_metric_series, known, methods);
551    let with_state = routes
552        .into_axum_router()
553        .layer(axum::Extension(state.clone()));
554    let observed = if config.metrics {
555        with_state.layer(MetricsLayer::new(shared.metrics.clone()))
556    } else {
557        with_state
558    };
559    Ok((Routes::from(observed), reporter))
560}
561
562impl Plugin for GrpcPlugin {
563    /// The name contains the config section. Two servers need two sections.
564    fn name(&self) -> Cow<'static, str> {
565        Cow::Owned(format!("{PLUGIN_NAME}@{}", self.section))
566    }
567
568    fn build(mut self, app: AppBuilder) -> AppBuilder {
569        let app = app.config_section(self.section.clone());
570        let (config, development, problem) = match self.resolved() {
571            Ok(resolved) => (resolved.config.clone(), resolved.is_development(), None),
572            Err(error) => (GrpcConfig::default(), false, Some(error.clone())),
573        };
574        let shared = self.shared.clone();
575
576        if let Some(error) = problem {
577            return fail_at_startup(app, shared, &GrpcError::Config(error));
578        }
579        // Clients do not need the server, so they come before `enabled`.
580        #[cfg(feature = "client")]
581        let app = {
582            let registrations = std::mem::take(&mut self.clients);
583            match crate::client::prepare(&app, registrations, &config, &self.section) {
584                Ok(Some(prepared)) => prepared.install(app),
585                Ok(None) => app,
586                Err(error) => return fail_at_startup(app, shared, &error),
587            }
588        };
589        if !config.enabled {
590            tracing::info!(section = %self.section, "gRPC server disabled by configuration");
591            return app;
592        }
593        let app = if config.listener == Listener::Shared {
594            if let Some(owner) = app.extension::<SharedOwner>() {
595                let error = GrpcError::SharedListenerTaken(owner.0.clone());
596                return fail_at_startup(app, shared, &error);
597            }
598            app.with_extension(SharedOwner(self.section.clone()))
599                .static_gate(GrpcGate::new(shared.clone()))
600        } else {
601            app
602        };
603
604        let pending = Arc::new(Mutex::new(Some(Pending {
605            registrations: std::mem::take(&mut self.registrations),
606            layers: std::mem::take(&mut self.layers),
607            descriptors: std::mem::take(&mut self.descriptors),
608        })));
609        let user_names = self.service_names.clone();
610        let section = self.section.clone();
611        let declared = self.route_infos();
612        let existing = app.extension::<GrpcServers>().cloned();
613        let first_server = existing.is_none();
614        let servers = existing.unwrap_or_default();
615        let app = if first_server {
616            app.with_extension(servers.clone())
617        } else {
618            app
619        };
620        servers.insert(section.clone(), GrpcHandle::new(shared.clone()));
621        let hook_servers = servers.clone();
622        let is_default = section == DEFAULT_SECTION;
623        let hook_shared = shared.clone();
624        let app = app.on_startup(move |state| {
625            let pending = pending.clone();
626            let shared = hook_shared.clone();
627            let config = config.clone();
628            let user_names = user_names.clone();
629            let servers = hook_servers.clone();
630            async move {
631                let taken = pending
632                    .lock()
633                    .unwrap_or_else(PoisonError::into_inner)
634                    .take();
635                let result = match taken {
636                    Some(pending) => {
637                        launch(&state, pending, config, development, &shared, &user_names).await
638                    }
639                    None => Err(GrpcError::AlreadyStarted),
640                };
641                match result {
642                    Ok(listening) => {
643                        tracing::info!(%listening, services = ?user_names, "gRPC server listening");
644                        state.insert_extension(servers);
645                        if is_default {
646                            state.insert_extension(GrpcHandle::new(shared));
647                        }
648                        Ok(())
649                    }
650                    Err(error) => {
651                        let _ = shared.lifecycle.apply(LifecycleEvent::StartFailed);
652                        shared.mark_stopped();
653                        Err(startup_error(&error))
654                    }
655                }
656            }
657        });
658
659        // Autumn keeps one family per metric name, so one source reports
660        // every server.
661        let app = if first_server {
662            app.metrics_source(PLUGIN_NAME, Arc::new(ServersMetrics(servers)))
663        } else {
664            app
665        };
666        let stop_shared = shared.clone();
667        app.declare_plugin_routes(declared)
668            .health_indicator(section, Arc::new(GrpcHealthIndicator { shared }))
669            .on_shutdown(move || {
670                let handle = GrpcHandle::new(stop_shared.clone());
671                async move { handle.shutdown().await }
672            })
673    }
674}
675
676/// Fail at startup, where Autumn reports hook errors.
677fn fail_at_startup(app: AppBuilder, shared: Arc<Shared>, error: &GrpcError) -> AppBuilder {
678    // `GrpcError` is not `Clone`; the hook can run more than once.
679    let message = error.to_string();
680    app.on_startup(move |_state| {
681        let shared = shared.clone();
682        let message = message.clone();
683        async move {
684            let _ = shared.lifecycle.apply(LifecycleEvent::StartFailed);
685            shared.mark_stopped();
686            Err(startup_message(&message))
687        }
688    })
689}
690
691/// Autumn runs plugin shutdown hooks after the HTTP drain, inside
692/// `server.shutdown_timeout_secs`. Autumn stops a hook that runs longer.
693/// The plugin caps the grace, and keeps time to close killed connections.
694fn fit_grace_to_autumn(config: &mut GrpcConfig, state: &AppState) {
695    let budget_ms = state
696        .config_arc()
697        .server
698        .shutdown_timeout_secs
699        .saturating_mul(1000);
700    // Leave time to close killed connections, inside the same budget.
701    let kill_ms = u64::try_from(crate::server::KILL_WAIT.as_millis()).unwrap_or(u64::MAX);
702    let limit = budget_ms.saturating_sub(kill_ms.min(budget_ms / 2)).max(1);
703    if config.shutdown_grace_ms > limit {
704        tracing::warn!(
705            shutdown_grace_ms = config.shutdown_grace_ms,
706            limit_ms = limit,
707            "gRPC shutdown_grace_ms does not fit in server.shutdown_timeout_secs; using the limit"
708        );
709        config.shutdown_grace_ms = limit;
710    }
711}
712
713/// Reflection shows the full API. Warn when it is on and the listener
714/// accepts calls from other hosts.
715fn warn_on_open_reflection(config: &GrpcConfig, development: bool) {
716    let open = config
717        .bind_addr(development)
718        .is_ok_and(|addr| !addr.ip().is_loopback());
719    if open && config.reflection.resolve(development) {
720        tracing::warn!(
721            bind = %config.bind,
722            "gRPC reflection is on and the listener is not on loopback; set `reflection = false` to hide the API"
723        );
724    }
725}
726
727/// Where the server listens, for the startup log.
728enum Listening {
729    Dedicated(std::net::SocketAddr),
730    Shared,
731}
732
733impl std::fmt::Display for Listening {
734    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735        match self {
736            Self::Dedicated(addr) => write!(f, "{addr}"),
737            Self::Shared => f.write_str("Autumn's HTTP port (shared)"),
738        }
739    }
740}
741
742async fn launch(
743    state: &AppState,
744    pending: Pending,
745    config: GrpcConfig,
746    development: bool,
747    shared: &Arc<Shared>,
748    user_names: &[&'static str],
749) -> Result<Listening, GrpcError> {
750    if config.listener == Listener::Shared {
751        launch_shared(state, pending, config, development, shared, user_names).await?;
752        return Ok(Listening::Shared);
753    }
754    launch_dedicated(state, pending, config, development, shared, user_names)
755        .await
756        .map(Listening::Dedicated)
757}
758
759/// Shared mode checks that need `AppState`. Logs the warnings.
760fn check_shared(state: &AppState, config: &GrpcConfig, development: bool) -> Result<(), GrpcError> {
761    let autumn = state.config_arc();
762    if autumn.server.tls.is_some() {
763        return Err(GrpcError::SharedListenerNeedsH2(SUPPORTED_AUTUMN_WEB));
764    }
765    for warning in shared_warnings(config, &autumn.server.host, development) {
766        tracing::warn!("{warning}");
767    }
768    Ok(())
769}
770
771/// Warnings for shared mode. There is at most one for the ignored
772/// settings.
773fn shared_warnings(config: &GrpcConfig, autumn_host: &str, development: bool) -> Vec<String> {
774    let mut warnings = Vec::new();
775    let ignored = config.dedicated_only_settings();
776    if !ignored.is_empty() {
777        warnings.push(format!(
778            "these gRPC settings have no effect with `listener = \"shared\"`, because Autumn's server owns the port: {}",
779            ignored.join(", ")
780        ));
781    }
782    let host = autumn_host.trim();
783    let loopback = host.eq_ignore_ascii_case("localhost")
784        || host
785            .parse::<std::net::IpAddr>()
786            .is_ok_and(|ip| ip.is_loopback());
787    if !loopback && config.reflection.resolve(development) {
788        warnings.push(format!(
789            "gRPC reflection is on and Autumn's port is not on loopback (host `{host}`); set `reflection = false` to hide the API"
790        ));
791    }
792    warnings
793}
794
795async fn launch_shared(
796    state: &AppState,
797    pending: Pending,
798    mut config: GrpcConfig,
799    development: bool,
800    shared: &Arc<Shared>,
801    user_names: &[&'static str],
802) -> Result<(), GrpcError> {
803    check_shared(state, &config, development)?;
804    fit_grace_to_autumn(&mut config, state);
805    let (routes, health) = build_routes(state, pending, &config, development, shared, user_names)?;
806    let health_names = user_names.iter().map(|n| (*n).to_owned()).collect();
807    crate::server::start_shared(
808        shared,
809        Launch {
810            config,
811            development,
812            routes,
813            health,
814            health_names,
815            #[cfg(feature = "tls")]
816            tls: None,
817        },
818    )
819    .await?;
820    crate::server::watch_readiness(shared, state.clone());
821    #[cfg(feature = "multiplex")]
822    crate::server::drain_on_autumn_shutdown(shared, state);
823    Ok(())
824}
825
826async fn launch_dedicated(
827    state: &AppState,
828    pending: Pending,
829    mut config: GrpcConfig,
830    development: bool,
831    shared: &Arc<Shared>,
832    user_names: &[&'static str],
833) -> Result<std::net::SocketAddr, GrpcError> {
834    // Check TLS before the bind, so a bad setup never serves plain text.
835    #[cfg(feature = "tls")]
836    let tls = crate::tls::server_config(&config.tls)?;
837    #[cfg(not(feature = "tls"))]
838    crate::tls::server_config(&config.tls)?;
839
840    fit_grace_to_autumn(&mut config, state);
841    warn_on_open_reflection(&config, development);
842    let (routes, health) = build_routes(state, pending, &config, development, shared, user_names)?;
843    let health_names = user_names.iter().map(|n| (*n).to_owned()).collect();
844    let addr = crate::server::start(
845        shared,
846        Launch {
847            config,
848            development,
849            routes,
850            health,
851            health_names,
852            #[cfg(feature = "tls")]
853            tls,
854        },
855    )
856    .await?;
857    crate::server::watch_readiness(shared, state.clone());
858    Ok(addr)
859}
860
861pub fn startup_error(error: &GrpcError) -> autumn_web::AutumnError {
862    startup_message(&error.to_string())
863}
864
865fn startup_message(message: &str) -> autumn_web::AutumnError {
866    autumn_web::AutumnError::internal_server_error_msg(format!("{PLUGIN_NAME}: {message}"))
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use crate::config::Toggle;
873
874    #[cfg(feature = "client")]
875    #[test]
876    fn a_registered_client_reads_env_overrides_and_resets_the_cache() {
877        use autumn_web::config::MockEnv;
878
879        let dir =
880            std::env::temp_dir().join(format!("autumn-grpc-plugin-env-{}", std::process::id()));
881        let _ = std::fs::create_dir_all(&dir);
882        let env = MockEnv::new()
883            .with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap_or_default())
884            .with(
885                "AUTUMN_GRPC__CLIENTS__LEDGER__ENDPOINT",
886                "http://ledger:7000",
887            )
888            .with("AUTUMN_GRPC__CLIENTS__LEDGER__TIMEOUT_MS", "250");
889        let plugin = GrpcPlugin::new();
890        let before = plugin.resolve(Some(&env)).map(|r| r.config);
891        assert!(
892            before.is_ok_and(|c| c.clients.is_empty()),
893            "no name, no leaf"
894        );
895
896        // Fill the cache, then register: `client` must reset it.
897        let _ = plugin.effective_config();
898        let plugin = plugin.client("ledger", |channel| channel);
899        assert!(plugin.resolved.get().is_none(), "client() resets the cache");
900        let after = plugin.resolve(Some(&env)).map(|r| r.config);
901        let ledger = after.as_ref().map(|c| c.clients.get("ledger").cloned());
902        assert!(
903            matches!(&ledger, Ok(Some(l)) if l.endpoint == "http://ledger:7000" && l.timeout_ms == 250),
904            "{ledger:?}"
905        );
906    }
907
908    #[test]
909    fn shared_mode_warns_once_for_ignored_settings() {
910        let mut config = GrpcConfig {
911            reflection: Toggle::Off,
912            ..GrpcConfig::default()
913        };
914        assert_eq!(
915            shared_warnings(&config, "0.0.0.0", false),
916            Vec::<String>::new()
917        );
918
919        config.bind = "127.0.0.1:0".to_owned();
920        config.max_connection_age_ms = 5;
921        let warnings = shared_warnings(&config, "0.0.0.0", false);
922        assert_eq!(warnings.len(), 1, "{warnings:?}");
923        assert!(warnings[0].contains("bind, max_connection_age_ms"));
924    }
925
926    #[test]
927    fn shared_mode_warns_for_reflection_off_loopback() {
928        let mut config = GrpcConfig {
929            reflection: Toggle::On,
930            ..GrpcConfig::default()
931        };
932        for host in ["127.0.0.1", "::1", "localhost", " LocalHost "] {
933            assert!(shared_warnings(&config, host, false).is_empty(), "{host}");
934        }
935        for host in ["0.0.0.0", "10.0.0.5", "example.com"] {
936            let warnings = shared_warnings(&config, host, false);
937            assert_eq!(warnings.len(), 1, "{host}");
938            assert!(warnings[0].contains("reflection"));
939        }
940        config.reflection = Toggle::Auto;
941        assert_eq!(shared_warnings(&config, "0.0.0.0", true).len(), 1, "dev");
942        assert!(
943            shared_warnings(&config, "0.0.0.0", false).is_empty(),
944            "prod"
945        );
946    }
947}