Skip to main content

ironflow_runtime/
runtime.rs

1//! The runtime server builder and HTTP serving logic.
2//!
3//! [`Runtime`] is the central entry-point for configuring and launching an
4//! ironflow daemon. It uses a builder pattern to register webhook routes and
5//! cron jobs, then starts an [Axum](https://docs.rs/axum) HTTP server with
6//! graceful shutdown on `Ctrl+C`.
7//!
8//! Webhook handlers are executed in the background via [`tokio::spawn`], so
9//! the HTTP endpoint responds with **202 Accepted** immediately while the
10//! workflow runs asynchronously.
11
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use axum::Router;
17use axum::body::Bytes;
18use axum::extract::{DefaultBodyLimit, State};
19use axum::http::{HeaderMap, StatusCode, header};
20use axum::middleware;
21use axum::routing::{get, post};
22use serde_json::{Value, from_slice};
23use tokio::sync::{Mutex, Semaphore};
24use tokio::task::JoinSet;
25use tokio_cron_scheduler::{Job, JobScheduler};
26use tracing::{error, info, warn};
27
28use tokio_util::sync::CancellationToken;
29
30use crate::cron::CronJob;
31use crate::error::RuntimeError;
32use crate::trigger::{Trigger, TriggerEvent, TriggerSink};
33use crate::webhook::{WebhookAuth, extract_delivery_id};
34
35/// Default buffer size for the trigger event channel.
36const DEFAULT_TRIGGER_CHANNEL_SIZE: usize = 256;
37
38/// Callback invoked when a [`Trigger`] emits a [`TriggerEvent`].
39type TriggerHandler =
40    Arc<dyn Fn(TriggerEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
41
42/// Default maximum body size for webhook payloads (2 MiB).
43const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
44
45/// Default maximum number of concurrently running webhook handlers.
46const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64;
47
48type WebhookHandler =
49    Arc<dyn Fn(WebhookContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
50type ShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
51
52/// What a webhook handler registered via [`Runtime::webhook_with_context`] receives.
53///
54/// # Examples
55///
56/// ```no_run
57/// use ironflow_runtime::prelude::*;
58///
59/// let runtime = Runtime::new().webhook_with_context(
60///     "/hooks/github",
61///     WebhookAuth::github("secret"),
62///     |ctx: WebhookContext| async move {
63///         println!("{} from {:?}", ctx.payload, ctx.delivery_id);
64///     },
65/// );
66/// ```
67#[derive(Debug, Clone)]
68pub struct WebhookContext {
69    /// The parsed JSON body.
70    pub payload: Value,
71    /// Provider delivery identifier, already prefixed (`github:…`, `gitlab:…`).
72    ///
73    /// `None` when the provider sent no known delivery header. Suitable as-is
74    /// for the `Idempotency-Key` header of `POST /api/v1/runs`.
75    pub delivery_id: Option<String>,
76}
77
78/// Metric name constants for the runtime (webhook + cron).
79#[cfg(feature = "prometheus")]
80mod metric_names {
81    pub const WEBHOOK_RECEIVED_TOTAL: &str = "ironflow_webhook_received_total";
82    pub const CRON_RUNS_TOTAL: &str = "ironflow_cron_runs_total";
83
84    pub const AUTH_REJECTED: &str = "rejected";
85    pub const AUTH_ACCEPTED: &str = "accepted";
86    pub const AUTH_INVALID_BODY: &str = "invalid_body";
87}
88
89struct WebhookRoute {
90    path: String,
91    auth: WebhookAuth,
92    handler: WebhookHandler,
93}
94
95/// The ironflow runtime server builder.
96///
97/// `Runtime` uses a builder pattern: create one with [`Runtime::new`], register
98/// webhook routes with [`Runtime::webhook`] and cron jobs with
99/// [`Runtime::cron`], then call [`Runtime::serve`] to start both the HTTP
100/// server and the cron scheduler, or [`Runtime::run_crons`] to run only the
101/// cron scheduler without an HTTP listener.
102///
103/// # Built-in endpoints
104///
105/// | Method | Path | Description |
106/// |--------|------|-------------|
107/// | `GET`  | `/health` | Returns `200 OK` with body `"ok"`. Useful for load-balancer health checks. |
108/// | `POST` | *user-defined* | Webhook endpoints registered via [`Runtime::webhook`]. |
109///
110/// # Examples
111///
112/// ```no_run
113/// use ironflow_runtime::prelude::*;
114///
115/// #[tokio::main]
116/// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
117///     Runtime::new()
118///         .webhook("/hooks/deploy", WebhookAuth::github("secret"), |payload| async move {
119///             println!("deploy triggered: {payload}");
120///         })
121///         .cron("0 0 * * * *", "hourly-sync", || async {
122///             println!("syncing...");
123///         })
124///         .serve("0.0.0.0:3000")
125///         .await?;
126///
127///     Ok(())
128/// }
129/// ```
130pub struct Runtime {
131    webhooks: Vec<WebhookRoute>,
132    crons: Vec<CronJob>,
133    triggers: Vec<Box<dyn Trigger>>,
134    trigger_handler: Option<TriggerHandler>,
135    max_body_size: usize,
136    max_concurrent_handlers: usize,
137    custom_shutdown: Option<ShutdownSignal>,
138}
139
140impl Runtime {
141    /// Creates a new, empty `Runtime` with no webhooks or cron jobs.
142    ///
143    /// # Examples
144    ///
145    /// ```no_run
146    /// use ironflow_runtime::runtime::Runtime;
147    ///
148    /// let runtime = Runtime::new();
149    /// ```
150    pub fn new() -> Self {
151        Self {
152            webhooks: Vec::new(),
153            crons: Vec::new(),
154            triggers: Vec::new(),
155            trigger_handler: None,
156            max_body_size: DEFAULT_MAX_BODY_SIZE,
157            max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS,
158            custom_shutdown: None,
159        }
160    }
161
162    /// Set the maximum allowed body size for webhook payloads.
163    ///
164    /// Requests exceeding this limit are rejected by axum before reaching the
165    /// handler. Defaults to 2 MiB.
166    ///
167    /// # Examples
168    ///
169    /// ```no_run
170    /// use ironflow_runtime::runtime::Runtime;
171    ///
172    /// let runtime = Runtime::new().max_body_size(512 * 1024); // 512 KiB
173    /// ```
174    pub fn max_body_size(mut self, bytes: usize) -> Self {
175        self.max_body_size = bytes;
176        self
177    }
178
179    /// Set the maximum number of concurrently running webhook handlers.
180    ///
181    /// When the limit is reached, new webhook requests still receive
182    /// **202 Accepted** but their handlers are queued until a slot is
183    /// available. Defaults to 64.
184    ///
185    /// # Panics
186    ///
187    /// Panics if `limit` is `0`.
188    ///
189    /// # Examples
190    ///
191    /// ```no_run
192    /// use ironflow_runtime::runtime::Runtime;
193    ///
194    /// let runtime = Runtime::new().max_concurrent_handlers(16);
195    /// ```
196    pub fn max_concurrent_handlers(mut self, limit: usize) -> Self {
197        assert!(limit > 0, "max_concurrent_handlers must be greater than 0");
198        self.max_concurrent_handlers = limit;
199        self
200    }
201
202    /// Override the default shutdown signal (`Ctrl+C` / `SIGTERM`).
203    ///
204    /// By default, [`Runtime::serve`] and [`Runtime::run_crons`] block until
205    /// the process receives `Ctrl+C` or `SIGTERM`. Use this method to provide
206    /// a custom future that resolves when the runtime should shut down.
207    ///
208    /// This is useful in tests where you want to trigger a clean shutdown
209    /// (including `scheduler.shutdown()`) without relying on OS signals.
210    ///
211    /// # Examples
212    ///
213    /// ```no_run
214    /// use ironflow_runtime::runtime::Runtime;
215    /// use tokio::sync::oneshot;
216    ///
217    /// # async fn example() -> Result<(), ironflow_runtime::error::RuntimeError> {
218    /// let (tx, rx) = oneshot::channel::<()>();
219    ///
220    /// let rt = Runtime::new()
221    ///     .with_shutdown(async { let _ = rx.await; })
222    ///     .cron("0 */5 * * * *", "check", || async {});
223    ///
224    /// // In another task: tx.send(()) to trigger shutdown.
225    /// rt.run_crons().await?;
226    /// # Ok(())
227    /// # }
228    /// ```
229    pub fn with_shutdown<F>(mut self, signal: F) -> Self
230    where
231        F: Future<Output = ()> + Send + 'static,
232    {
233        self.custom_shutdown = Some(Box::pin(signal));
234        self
235    }
236
237    /// Registers a webhook route.
238    ///
239    /// The handler receives the parsed JSON body as a [`serde_json::Value`].
240    /// When a request arrives, the server verifies authentication using `auth`,
241    /// then spawns the handler in the background and immediately returns
242    /// **202 Accepted** to the caller.
243    ///
244    /// # Arguments
245    ///
246    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
247    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
248    /// * `handler` - An async function receiving the JSON payload.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// use ironflow_runtime::prelude::*;
254    ///
255    /// let runtime = Runtime::new()
256    ///     .webhook("/hooks/github", WebhookAuth::github("secret"), |payload| async move {
257    ///         println!("payload: {payload}");
258    ///     });
259    /// ```
260    pub fn webhook<F, Fut>(self, path: &str, auth: WebhookAuth, handler: F) -> Self
261    where
262        F: Fn(Value) -> Fut + Send + Sync + Clone + 'static,
263        Fut: Future<Output = ()> + Send + 'static,
264    {
265        self.webhook_with_context(path, auth, move |ctx| handler(ctx.payload))
266    }
267
268    /// Registers a webhook route whose handler also sees the delivery identifier.
269    ///
270    /// Identical to [`Runtime::webhook`], except the handler receives a
271    /// [`WebhookContext`] instead of the bare payload. Use this when the workflow
272    /// should be enqueued idempotently: replaying the same provider delivery then
273    /// resolves to the run it already created rather than a duplicate.
274    ///
275    /// # Arguments
276    ///
277    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
278    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
279    /// * `handler` - An async function receiving the [`WebhookContext`].
280    ///
281    /// # Panics
282    ///
283    /// Panics if `path` does not start with `/`.
284    ///
285    /// # Examples
286    ///
287    /// ```no_run
288    /// use ironflow_runtime::prelude::*;
289    ///
290    /// let runtime = Runtime::new().webhook_with_context(
291    ///     "/hooks/github",
292    ///     WebhookAuth::github("secret"),
293    ///     |ctx| async move {
294    ///         // `ctx.delivery_id` is `Some("github:<X-GitHub-Delivery>")` when
295    ///         // the provider stamped the request.
296    ///         println!("delivery: {:?}", ctx.delivery_id);
297    ///     },
298    /// );
299    /// ```
300    pub fn webhook_with_context<F, Fut>(mut self, path: &str, auth: WebhookAuth, handler: F) -> Self
301    where
302        F: Fn(WebhookContext) -> Fut + Send + Sync + Clone + 'static,
303        Fut: Future<Output = ()> + Send + 'static,
304    {
305        assert!(
306            path.starts_with('/'),
307            "webhook path must start with '/', got: {path}"
308        );
309        if matches!(auth, WebhookAuth::None) {
310            warn!(path = %path, "webhook registered with WebhookAuth::None - all requests will be accepted without authentication");
311        }
312        let handler: WebhookHandler = Arc::new(move |ctx| {
313            let handler = handler.clone();
314            Box::pin(async move { handler(ctx).await })
315        });
316        self.webhooks.push(WebhookRoute {
317            path: path.to_string(),
318            auth,
319            handler,
320        });
321        self
322    }
323
324    /// Registers a cron job.
325    ///
326    /// The `schedule` uses a **6-field cron expression** (seconds granularity):
327    /// `sec min hour day-of-month month day-of-week`.
328    ///
329    /// # Arguments
330    ///
331    /// * `schedule` - A 6-field cron expression, e.g. `"0 */5 * * * *"` for every 5 minutes.
332    /// * `name` - A human-readable name for logging.
333    /// * `handler` - An async function to execute on each tick.
334    ///
335    /// # Examples
336    ///
337    /// ```no_run
338    /// use ironflow_runtime::prelude::*;
339    ///
340    /// let runtime = Runtime::new()
341    ///     .cron("0 0 * * * *", "hourly-cleanup", || async {
342    ///         println!("cleaning up...");
343    ///     });
344    /// ```
345    pub fn cron<F, Fut>(mut self, schedule: &str, name: &str, handler: F) -> Self
346    where
347        F: Fn() -> Fut + Send + Sync + 'static,
348        Fut: Future<Output = ()> + Send + 'static,
349    {
350        let handler_fn: Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
351            Box::new(move || Box::pin(handler()));
352        self.crons.push(CronJob {
353            schedule: schedule.to_string(),
354            name: name.to_string(),
355            handler: handler_fn,
356        });
357        self
358    }
359
360    /// Registers a [`Trigger`] that will be started alongside the
361    /// HTTP server and cron scheduler.
362    ///
363    /// Triggers emit [`TriggerEvent`]s through a channel. Use
364    /// [`Runtime::on_trigger`] to handle those events (typically by
365    /// creating a workflow run).
366    ///
367    /// # Examples
368    ///
369    /// ```no_run
370    /// use ironflow_runtime::prelude::*;
371    /// use ironflow_runtime::trigger::event::{EventTrigger, EventTriggerRule};
372    /// use ironflow_store::entities::EventKind;
373    ///
374    /// let trigger = EventTrigger::new(vec![
375    ///     EventTriggerRule {
376    ///         on_event: EventKind::RunFailed,
377    ///         source_workflow: "deploy".to_string(),
378    ///         target_workflow: "rollback".to_string(),
379    ///         max_chain_depth: 3,
380    ///     },
381    /// ]);
382    ///
383    /// let runtime = Runtime::new().trigger(trigger);
384    /// ```
385    pub fn trigger(mut self, trigger: impl Trigger + 'static) -> Self {
386        self.triggers.push(Box::new(trigger));
387        self
388    }
389
390    /// Set the handler called when a [`Trigger`] emits a
391    /// [`TriggerEvent`].
392    ///
393    /// The handler typically creates a workflow run via the API.
394    ///
395    /// # Examples
396    ///
397    /// ```no_run
398    /// use ironflow_runtime::prelude::*;
399    ///
400    /// let runtime = Runtime::new()
401    ///     .on_trigger(|event| async move {
402    ///         println!("trigger fired: {} -> {}", event.workflow_name, event.payload);
403    ///     });
404    /// ```
405    pub fn on_trigger<F, Fut>(mut self, handler: F) -> Self
406    where
407        F: Fn(TriggerEvent) -> Fut + Send + Sync + 'static,
408        Fut: Future<Output = ()> + Send + 'static,
409    {
410        self.trigger_handler = Some(Arc::new(move |event| Box::pin(handler(event))));
411        self
412    }
413
414    /// Build the axum [`Router`] from the registered webhooks.
415    ///
416    /// This is separated from [`Runtime::serve`] so the router can be tested
417    /// independently (e.g. with `tower::ServiceExt::oneshot` or by
418    /// binding to a random port in integration tests).
419    fn build_router(
420        webhooks: Vec<WebhookRoute>,
421        handler_tracker: Arc<HandlerTracker>,
422        max_body_size: usize,
423        #[cfg(feature = "prometheus")] prom_handle: Option<
424            metrics_exporter_prometheus::PrometheusHandle,
425        >,
426    ) -> Router {
427        let mut router = Router::new();
428
429        for webhook in webhooks {
430            let auth = Arc::new(webhook.auth);
431            let handler = webhook.handler;
432            let path = webhook.path.clone();
433
434            let name: Arc<str> = Arc::from(path.as_str());
435            let route_state = WebhookState {
436                auth,
437                handler,
438                name,
439                tracker: handler_tracker.clone(),
440            };
441
442            router = router.route(&path, post(webhook_handler).with_state(route_state));
443            info!(path = %path, "registered webhook");
444        }
445
446        router = router.route("/health", get(|| async { "ok" }));
447
448        #[cfg(feature = "prometheus")]
449        if let Some(handle) = prom_handle {
450            router = router.route(
451                "/metrics",
452                get(move || {
453                    let h = handle.clone();
454                    async move { h.render() }
455                }),
456            );
457            info!("registered /metrics endpoint");
458        }
459
460        router
461            .layer(middleware::from_fn(security_headers))
462            .layer(DefaultBodyLimit::max(max_body_size))
463    }
464
465    /// Consumes the runtime and returns only the axum [`Router`].
466    ///
467    /// Cron jobs are **not** started. This is useful for testing the HTTP
468    /// layer in isolation without side-effects (e.g. with
469    /// `tower::ServiceExt::oneshot`).
470    ///
471    /// # Examples
472    ///
473    /// ```no_run
474    /// use ironflow_runtime::prelude::*;
475    ///
476    /// let router = Runtime::new()
477    ///     .webhook("/hooks/test", WebhookAuth::none(), |_payload| async {})
478    ///     .into_router();
479    /// ```
480    pub fn into_router(self) -> Router {
481        if !self.crons.is_empty() {
482            warn!(
483                cron_count = self.crons.len(),
484                "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
485            );
486        }
487        let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
488        Self::build_router(
489            self.webhooks,
490            tracker,
491            self.max_body_size,
492            #[cfg(feature = "prometheus")]
493            None,
494        )
495    }
496
497    /// Starts the cron scheduler with all registered cron jobs.
498    ///
499    /// This is an internal helper used by both [`Runtime::serve`] and
500    /// [`Runtime::run_crons`].
501    async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
502        let scheduler = JobScheduler::new().await?;
503
504        for cron_job in crons {
505            let handler = Arc::new(cron_job.handler);
506            let name = cron_job.name.clone();
507            let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
508            let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
509                let handler = handler.clone();
510                let name = name.clone();
511                let running = running.clone();
512                Box::pin(async move {
513                    if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
514                        warn!(cron = %name, "cron job still running, skipping this tick");
515                        return;
516                    }
517                    info!(cron = %name, "cron job triggered");
518                    #[cfg(feature = "prometheus")]
519                    metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
520                        .increment(1);
521                    (handler)().await;
522                    running.store(false, std::sync::atomic::Ordering::Release);
523                })
524            })?;
525            info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
526            scheduler.add(job).await?;
527        }
528
529        scheduler.start().await?;
530        Ok(scheduler)
531    }
532
533    /// Starts only the cron scheduler, blocking until a shutdown signal is
534    /// received (`Ctrl+C` / `SIGTERM`).
535    ///
536    /// Unlike [`Runtime::serve`], this does **not** start an HTTP server. Any
537    /// registered webhooks are ignored (a warning is logged if webhooks were
538    /// registered).
539    ///
540    /// # Errors
541    ///
542    /// Returns an error if:
543    ///
544    /// - The cron scheduler fails to initialise or a cron expression is invalid.
545    /// - The scheduler fails to shut down cleanly.
546    ///
547    /// # Examples
548    ///
549    /// ```no_run
550    /// use ironflow_runtime::prelude::*;
551    ///
552    /// #[tokio::main]
553    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
554    ///     Runtime::new()
555    ///         .cron("0 0 * * * *", "hourly-sync", || async {
556    ///             println!("syncing...");
557    ///         })
558    ///         .run_crons()
559    ///         .await?;
560    ///     Ok(())
561    /// }
562    /// ```
563    pub async fn run_crons(self) -> Result<(), RuntimeError> {
564        let _ = dotenvy::dotenv();
565
566        if !self.webhooks.is_empty() {
567            warn!(
568                webhook_count = self.webhooks.len(),
569                "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
570            );
571        }
572
573        #[cfg(feature = "prometheus")]
574        {
575            match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
576                Ok(_) => info!("prometheus metrics recorder installed"),
577                Err(_) => {
578                    info!("prometheus metrics recorder already installed, reusing existing")
579                }
580            }
581        }
582
583        let mut scheduler = Self::start_scheduler(self.crons).await?;
584
585        info!("ironflow cron scheduler running (no HTTP server)");
586        match self.custom_shutdown {
587            Some(signal) => signal.await,
588            None => shutdown_signal().await,
589        }
590
591        info!("shutting down scheduler");
592        scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
593        info!("ironflow cron scheduler stopped");
594
595        Ok(())
596    }
597
598    /// Starts the HTTP server and cron scheduler, blocking until shutdown.
599    ///
600    /// This method:
601    ///
602    /// 1. Loads environment variables from `.env` via [`dotenvy`].
603    /// 2. Starts the [`tokio_cron_scheduler`] scheduler with all registered cron jobs.
604    /// 3. Builds an [Axum](https://docs.rs/axum) router with all registered webhook
605    ///    routes plus a `GET /health` endpoint.
606    /// 4. Binds to `addr` and serves until a `Ctrl+C` signal is received.
607    /// 5. Gracefully shuts down the scheduler before returning.
608    ///
609    /// If you only need cron jobs without an HTTP server, use
610    /// [`Runtime::run_crons`] instead.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if:
615    ///
616    /// - The cron scheduler fails to initialise or a cron expression is invalid.
617    /// - The TCP listener cannot bind to `addr`.
618    /// - The Axum server encounters a fatal I/O error.
619    ///
620    /// # Examples
621    ///
622    /// ```no_run
623    /// use ironflow_runtime::prelude::*;
624    ///
625    /// #[tokio::main]
626    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
627    ///     Runtime::new()
628    ///         .serve("127.0.0.1:3000")
629    ///         .await?;
630    ///     Ok(())
631    /// }
632    /// ```
633    pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
634        let _ = dotenvy::dotenv();
635
636        #[cfg(feature = "prometheus")]
637        let prom_handle = {
638            match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
639                Ok(handle) => {
640                    info!("prometheus metrics recorder installed");
641                    Some(handle)
642                }
643                Err(_) => {
644                    info!("prometheus metrics recorder already installed, reusing existing");
645                    None
646                }
647            }
648        };
649
650        let mut scheduler = Self::start_scheduler(self.crons).await?;
651
652        // Start triggers
653        let trigger_token = CancellationToken::new();
654        let trigger_handles =
655            Self::start_triggers(self.triggers, self.trigger_handler, trigger_token.clone());
656
657        let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
658        let router = Self::build_router(
659            self.webhooks,
660            tracker.clone(),
661            self.max_body_size,
662            #[cfg(feature = "prometheus")]
663            prom_handle,
664        );
665
666        let listener = tokio::net::TcpListener::bind(addr)
667            .await
668            .map_err(RuntimeError::Bind)?;
669        info!(addr = %addr, "ironflow runtime listening");
670
671        let graceful_shutdown = match self.custom_shutdown {
672            Some(signal) => signal,
673            None => Box::pin(shutdown_signal()),
674        };
675        axum::serve(listener, router)
676            .with_graceful_shutdown(graceful_shutdown)
677            .await
678            .map_err(RuntimeError::Serve)?;
679
680        // Wait for all in-flight webhook handlers to finish.
681        info!("waiting for in-flight webhook handlers to complete");
682        tracker.wait().await;
683
684        // Stop triggers
685        info!("stopping triggers");
686        trigger_token.cancel();
687        for handle in trigger_handles {
688            if let Err(e) = handle.await {
689                error!(error = %e, "trigger task panicked");
690            }
691        }
692
693        info!("shutting down scheduler");
694        scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
695        info!("ironflow runtime stopped");
696
697        Ok(())
698    }
699
700    /// Start all registered triggers and the event-dispatch loop.
701    ///
702    /// Returns handles for each trigger task and the dispatch task.
703    fn start_triggers(
704        triggers: Vec<Box<dyn Trigger>>,
705        handler: Option<TriggerHandler>,
706        token: CancellationToken,
707    ) -> Vec<tokio::task::JoinHandle<()>> {
708        if triggers.is_empty() {
709            return Vec::new();
710        }
711
712        let (sink, mut rx) = TriggerSink::channel(DEFAULT_TRIGGER_CHANNEL_SIZE);
713        let mut handles = Vec::new();
714
715        for trigger in triggers {
716            let sink = sink.clone();
717            let token = token.clone();
718            let name = trigger.name().to_string();
719            let handle = tokio::spawn(async move {
720                info!(trigger = %name, "starting trigger");
721                if let Err(e) = trigger.start(sink, &token).await {
722                    error!(trigger = %name, error = %e, "trigger failed");
723                }
724                info!(trigger = %name, "trigger stopped");
725            });
726            handles.push(handle);
727        }
728
729        // Dispatch loop: forward trigger events to the handler.
730        if let Some(handler) = handler {
731            let dispatch_token = token.clone();
732            let dispatch_handle = tokio::spawn(async move {
733                loop {
734                    tokio::select! {
735                        _ = dispatch_token.cancelled() => break,
736                        event = rx.recv() => {
737                            let Some(event) = event else { break };
738                            info!(
739                                workflow = %event.workflow_name,
740                                "trigger event received, dispatching"
741                            );
742                            handler(event).await;
743                        }
744                    }
745                }
746            });
747            handles.push(dispatch_handle);
748        } else if !handles.is_empty() {
749            warn!(
750                "triggers registered but no on_trigger handler set - trigger events will be dropped"
751            );
752        }
753
754        handles
755    }
756}
757
758impl Default for Runtime {
759    fn default() -> Self {
760        Self::new()
761    }
762}
763
764/// Tracks in-flight webhook handlers and enforces a concurrency limit.
765///
766/// Combines a [`Semaphore`] for backpressure with a [`JoinSet`] so that
767/// [`Runtime::serve`] can wait for all running handlers before exiting.
768struct HandlerTracker {
769    semaphore: Arc<Semaphore>,
770    join_set: Mutex<JoinSet<()>>,
771}
772
773impl HandlerTracker {
774    fn new(max_concurrent: usize) -> Self {
775        Self {
776            semaphore: Arc::new(Semaphore::new(max_concurrent)),
777            join_set: Mutex::new(JoinSet::new()),
778        }
779    }
780
781    /// Spawn a handler task, respecting the concurrency limit.
782    async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
783        let semaphore = self.semaphore.clone();
784        let mut js = self.join_set.lock().await;
785        // Reap completed tasks to detect panics early.
786        while let Some(result) = js.try_join_next() {
787            if let Err(e) = result {
788                error!(error = %e, "webhook handler panicked");
789            }
790        }
791        use tracing::Instrument;
792        let span = tracing::info_span!("webhook", path = %name);
793        js.spawn(
794            async move {
795                let _permit = semaphore
796                    .acquire()
797                    .await
798                    .expect("semaphore closed unexpectedly");
799                info!("webhook workflow started");
800                handler(ctx).await;
801                info!("webhook workflow completed");
802            }
803            .instrument(span),
804        );
805    }
806
807    /// Wait for all in-flight handlers to complete.
808    async fn wait(&self) {
809        let mut js = self.join_set.lock().await;
810        while let Some(result) = js.join_next().await {
811            if let Err(e) = result {
812                error!(error = %e, "webhook handler panicked");
813            }
814        }
815    }
816}
817
818#[derive(Clone)]
819struct WebhookState {
820    auth: Arc<WebhookAuth>,
821    handler: WebhookHandler,
822    name: Arc<str>,
823    tracker: Arc<HandlerTracker>,
824}
825
826async fn webhook_handler(
827    State(state): State<WebhookState>,
828    headers: HeaderMap,
829    body: Bytes,
830) -> StatusCode {
831    let name = &state.name;
832    if !state.auth.verify(&headers, &body) {
833        warn!(webhook = %name, "webhook auth failed");
834        #[cfg(feature = "prometheus")]
835        {
836            let label: String = name.to_string();
837            metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
838        }
839        return StatusCode::UNAUTHORIZED;
840    }
841
842    let payload: Value = match from_slice(&body) {
843        Ok(v) => v,
844        Err(e) => {
845            warn!(webhook = %name, error = %e, "invalid JSON body");
846            #[cfg(feature = "prometheus")]
847            {
848                let label: String = name.to_string();
849                metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
850            }
851            return StatusCode::BAD_REQUEST;
852        }
853    };
854
855    #[cfg(feature = "prometheus")]
856    {
857        let label: String = name.to_string();
858        metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
859    }
860
861    let ctx = WebhookContext {
862        payload,
863        delivery_id: extract_delivery_id(&headers),
864    };
865
866    state
867        .tracker
868        .spawn(name.to_string(), state.handler.clone(), ctx)
869        .await;
870
871    StatusCode::ACCEPTED
872}
873
874async fn security_headers(
875    request: axum::http::Request<axum::body::Body>,
876    next: axum::middleware::Next,
877) -> axum::response::Response {
878    let mut response = next.run(request).await;
879    let headers = response.headers_mut();
880    headers.insert(
881        header::X_CONTENT_TYPE_OPTIONS,
882        "nosniff".parse().expect("valid header value"),
883    );
884    headers.insert(
885        header::X_FRAME_OPTIONS,
886        "DENY".parse().expect("valid header value"),
887    );
888    headers.insert(
889        "x-xss-protection",
890        "1; mode=block".parse().expect("valid header value"),
891    );
892    headers.insert(
893        header::STRICT_TRANSPORT_SECURITY,
894        "max-age=31536000; includeSubDomains"
895            .parse()
896            .expect("valid header value"),
897    );
898    headers.insert(
899        header::CONTENT_SECURITY_POLICY,
900        "default-src 'none'".parse().expect("valid header value"),
901    );
902    response
903}
904
905async fn shutdown_signal() {
906    let ctrl_c = async {
907        if let Err(e) = tokio::signal::ctrl_c().await {
908            warn!("failed to install ctrl+c handler: {e}");
909        }
910    };
911
912    #[cfg(unix)]
913    {
914        use tokio::signal::unix::{SignalKind, signal};
915        let mut sigterm =
916            signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
917        tokio::select! {
918            () = ctrl_c => info!("received SIGINT, shutting down"),
919            _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
920        }
921    }
922
923    #[cfg(not(unix))]
924    {
925        ctrl_c.await;
926        info!("received ctrl+c, shutting down");
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    /// Test that Runtime::new() creates a runtime with default values.
935    #[test]
936    fn runtime_new_creates_with_defaults() {
937        let rt = Runtime::new();
938        assert_eq!(rt.webhooks.len(), 0);
939        assert_eq!(rt.crons.len(), 0);
940        assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
941        assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
942        assert!(rt.custom_shutdown.is_none());
943    }
944
945    /// Test that Runtime::default() is equivalent to Runtime::new().
946    #[test]
947    fn runtime_default_equals_new() {
948        let rt_new = Runtime::new();
949        let rt_default = Runtime::default();
950        assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
951        assert_eq!(rt_new.crons.len(), rt_default.crons.len());
952        assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
953        assert_eq!(
954            rt_new.max_concurrent_handlers,
955            rt_default.max_concurrent_handlers
956        );
957    }
958
959    /// Test that max_body_size() builder method sets the value and returns self.
960    #[test]
961    fn max_body_size_sets_value_and_returns_self() {
962        let rt = Runtime::new().max_body_size(512 * 1024);
963        assert_eq!(rt.max_body_size, 512 * 1024);
964    }
965
966    /// Test that max_body_size() can be chained with other builder methods.
967    #[test]
968    fn max_body_size_chainable() {
969        let rt =
970            Runtime::new()
971                .max_body_size(1024)
972                .webhook("/test", WebhookAuth::none(), |_| async {});
973        assert_eq!(rt.max_body_size, 1024);
974        assert_eq!(rt.webhooks.len(), 1);
975    }
976
977    /// Test that max_body_size() can be set to zero.
978    #[test]
979    fn max_body_size_can_be_zero() {
980        let rt = Runtime::new().max_body_size(0);
981        assert_eq!(rt.max_body_size, 0);
982    }
983
984    /// Test that max_body_size() can be set to large values.
985    #[test]
986    fn max_body_size_can_be_large() {
987        let large_size = 1024 * 1024 * 1024; // 1 GiB
988        let rt = Runtime::new().max_body_size(large_size);
989        assert_eq!(rt.max_body_size, large_size);
990    }
991
992    /// Test that max_concurrent_handlers() panics when given 0.
993    #[test]
994    #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
995    fn max_concurrent_handlers_zero_panics() {
996        let _ = Runtime::new().max_concurrent_handlers(0);
997    }
998
999    /// Test that max_concurrent_handlers() sets the value for valid inputs.
1000    #[test]
1001    fn max_concurrent_handlers_sets_valid_values() {
1002        let rt = Runtime::new().max_concurrent_handlers(16);
1003        assert_eq!(rt.max_concurrent_handlers, 16);
1004    }
1005
1006    /// Test that max_concurrent_handlers() with 1 is allowed.
1007    #[test]
1008    fn max_concurrent_handlers_one_is_valid() {
1009        let rt = Runtime::new().max_concurrent_handlers(1);
1010        assert_eq!(rt.max_concurrent_handlers, 1);
1011    }
1012
1013    /// Test that max_concurrent_handlers() with large values is allowed.
1014    #[test]
1015    fn max_concurrent_handlers_large_value_is_valid() {
1016        let large_limit = 10000;
1017        let rt = Runtime::new().max_concurrent_handlers(large_limit);
1018        assert_eq!(rt.max_concurrent_handlers, large_limit);
1019    }
1020
1021    /// Test that max_concurrent_handlers() returns self for chaining.
1022    #[test]
1023    fn max_concurrent_handlers_chainable() {
1024        let rt = Runtime::new().max_concurrent_handlers(32).webhook(
1025            "/test",
1026            WebhookAuth::none(),
1027            |_| async {},
1028        );
1029        assert_eq!(rt.max_concurrent_handlers, 32);
1030        assert_eq!(rt.webhooks.len(), 1);
1031    }
1032
1033    /// Test that with_shutdown() sets a custom shutdown signal and returns self.
1034    #[tokio::test]
1035    async fn with_shutdown_sets_signal_and_returns_self() {
1036        let (tx, rx) = tokio::sync::oneshot::channel();
1037        let rt = Runtime::new().with_shutdown(async move {
1038            let _ = rx.await;
1039        });
1040        assert!(rt.custom_shutdown.is_some());
1041
1042        // Signal to verify it was set properly.
1043        let _ = tx.send(());
1044    }
1045
1046    /// Test that with_shutdown() is chainable.
1047    #[tokio::test]
1048    async fn with_shutdown_chainable() {
1049        let (tx, rx) = tokio::sync::oneshot::channel();
1050        let rt = Runtime::new()
1051            .with_shutdown(async move {
1052                let _ = rx.await;
1053            })
1054            .webhook("/test", WebhookAuth::none(), |_| async {});
1055        assert!(rt.custom_shutdown.is_some());
1056        assert_eq!(rt.webhooks.len(), 1);
1057
1058        let _ = tx.send(());
1059    }
1060
1061    /// Test that webhook() registers a route and returns self.
1062    #[test]
1063    fn webhook_registers_route_and_returns_self() {
1064        let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
1065        assert_eq!(rt.webhooks.len(), 1);
1066        assert_eq!(rt.webhooks[0].path, "/hooks/test");
1067    }
1068
1069    /// Test that webhook() panics if path does not start with '/'.
1070    #[test]
1071    #[should_panic(expected = "webhook path must start with '/'")]
1072    fn webhook_path_without_slash_panics() {
1073        let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
1074    }
1075
1076    /// Test that webhook() accepts paths with various formats.
1077    #[test]
1078    fn webhook_accepts_valid_paths() {
1079        let rt = Runtime::new()
1080            .webhook("/", WebhookAuth::none(), |_| async {})
1081            .webhook("/simple", WebhookAuth::none(), |_| async {})
1082            .webhook("/nested/path", WebhookAuth::none(), |_| async {})
1083            .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
1084            .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
1085            .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
1086        assert_eq!(rt.webhooks.len(), 6);
1087    }
1088
1089    /// Test that webhook() can be chained multiple times.
1090    #[test]
1091    fn webhook_chainable() {
1092        let rt = Runtime::new()
1093            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1094            .webhook("/hook-b", WebhookAuth::none(), |_| async {})
1095            .webhook("/hook-c", WebhookAuth::none(), |_| async {});
1096        assert_eq!(rt.webhooks.len(), 3);
1097        assert_eq!(rt.webhooks[0].path, "/hook-a");
1098        assert_eq!(rt.webhooks[1].path, "/hook-b");
1099        assert_eq!(rt.webhooks[2].path, "/hook-c");
1100    }
1101
1102    /// Test that webhook() works with different auth types.
1103    #[test]
1104    fn webhook_with_various_auth_types() {
1105        let rt = Runtime::new()
1106            .webhook("/none", WebhookAuth::none(), |_| async {})
1107            .webhook(
1108                "/header",
1109                WebhookAuth::header("x-api-key", "secret"),
1110                |_| async {},
1111            )
1112            .webhook("/github", WebhookAuth::github("secret"), |_| async {})
1113            .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
1114        assert_eq!(rt.webhooks.len(), 4);
1115    }
1116
1117    /// Test that cron() registers a job and returns self.
1118    #[test]
1119    fn cron_registers_job_and_returns_self() {
1120        let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
1121        assert_eq!(rt.crons.len(), 1);
1122        assert_eq!(rt.crons[0].name, "daily-task");
1123        assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
1124    }
1125
1126    /// Test that cron() is chainable.
1127    #[test]
1128    fn cron_chainable() {
1129        let rt = Runtime::new()
1130            .cron("0 0 * * * *", "midnight", || async {})
1131            .cron("0 */5 * * * *", "every-5-minutes", || async {});
1132        assert_eq!(rt.crons.len(), 2);
1133    }
1134
1135    /// Test that cron() preserves all parameters correctly.
1136    #[test]
1137    fn cron_preserves_schedule_and_name() {
1138        let rt = Runtime::new()
1139            .cron("0 12 * * * MON", "noon-mondays", || async {})
1140            .cron("0 0 1 * * *", "first-of-month", || async {});
1141        assert_eq!(rt.crons[0].name, "noon-mondays");
1142        assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
1143        assert_eq!(rt.crons[1].name, "first-of-month");
1144        assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
1145    }
1146
1147    /// Test that into_router() returns a Router (compiles and doesn't panic).
1148    #[test]
1149    fn into_router_returns_router() {
1150        let rt = Runtime::new();
1151        let _router = rt.into_router();
1152        // If this compiles and doesn't panic, the router was successfully created.
1153    }
1154
1155    /// Test that into_router() with webhooks returns a Router.
1156    #[test]
1157    fn into_router_with_webhooks_returns_router() {
1158        let rt = Runtime::new()
1159            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1160            .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1161        let _router = rt.into_router();
1162        // If this compiles and doesn't panic, the router was successfully created with all webhooks.
1163    }
1164
1165    /// Test that into_router() with cron jobs (warns but doesn't panic).
1166    #[test]
1167    fn into_router_with_crons_returns_router() {
1168        let rt = Runtime::new()
1169            .cron("0 0 * * * *", "daily", || async {})
1170            .cron("0 */5 * * * *", "every-5-min", || async {});
1171        let _router = rt.into_router();
1172        // Crons are dropped but not an error; router should still be created.
1173    }
1174
1175    /// Test that into_router() with max_body_size returns a Router.
1176    #[test]
1177    fn into_router_respects_max_body_size_config() {
1178        let rt =
1179            Runtime::new()
1180                .max_body_size(100)
1181                .webhook("/hook", WebhookAuth::none(), |_| async {});
1182        let _router = rt.into_router();
1183        // Router created; actual body size limit enforcement is tested in integration tests.
1184    }
1185
1186    /// Test that into_router() with max_concurrent_handlers returns a Router.
1187    #[test]
1188    fn into_router_respects_max_concurrent_handlers_config() {
1189        let rt = Runtime::new().max_concurrent_handlers(16).webhook(
1190            "/hook",
1191            WebhookAuth::none(),
1192            |_| async {},
1193        );
1194        let _router = rt.into_router();
1195        // Router created; concurrency limit enforcement is tested in integration tests.
1196    }
1197
1198    /// Test full builder chain with multiple methods.
1199    #[test]
1200    fn builder_chain_multiple_methods() {
1201        let rt = Runtime::new()
1202            .max_body_size(512 * 1024)
1203            .max_concurrent_handlers(32)
1204            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1205            .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
1206            .cron("0 0 * * * *", "daily", || async {});
1207
1208        assert_eq!(rt.max_body_size, 512 * 1024);
1209        assert_eq!(rt.max_concurrent_handlers, 32);
1210        assert_eq!(rt.webhooks.len(), 2);
1211        assert_eq!(rt.crons.len(), 1);
1212    }
1213
1214    /// Test that into_router() drops cron jobs with a warning logged.
1215    #[test]
1216    fn into_router_with_crons_doesnt_start_them() {
1217        let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1218        // This should not panic; crons are simply dropped.
1219        let _router = rt.into_router();
1220    }
1221}