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    ///         conditions: vec![],
381    ///     },
382    /// ]);
383    ///
384    /// let runtime = Runtime::new().trigger(trigger);
385    /// ```
386    pub fn trigger(mut self, trigger: impl Trigger + 'static) -> Self {
387        self.triggers.push(Box::new(trigger));
388        self
389    }
390
391    /// Set the handler called when a [`Trigger`] emits a
392    /// [`TriggerEvent`].
393    ///
394    /// The handler typically creates a workflow run via the API.
395    ///
396    /// # Examples
397    ///
398    /// ```no_run
399    /// use ironflow_runtime::prelude::*;
400    ///
401    /// let runtime = Runtime::new()
402    ///     .on_trigger(|event| async move {
403    ///         println!("trigger fired: {} -> {}", event.workflow_name, event.payload);
404    ///     });
405    /// ```
406    pub fn on_trigger<F, Fut>(mut self, handler: F) -> Self
407    where
408        F: Fn(TriggerEvent) -> Fut + Send + Sync + 'static,
409        Fut: Future<Output = ()> + Send + 'static,
410    {
411        self.trigger_handler = Some(Arc::new(move |event| Box::pin(handler(event))));
412        self
413    }
414
415    /// Build the axum [`Router`] from the registered webhooks.
416    ///
417    /// This is separated from [`Runtime::serve`] so the router can be tested
418    /// independently (e.g. with `tower::ServiceExt::oneshot` or by
419    /// binding to a random port in integration tests).
420    fn build_router(
421        webhooks: Vec<WebhookRoute>,
422        handler_tracker: Arc<HandlerTracker>,
423        max_body_size: usize,
424        #[cfg(feature = "prometheus")] prom_handle: Option<
425            metrics_exporter_prometheus::PrometheusHandle,
426        >,
427    ) -> Router {
428        let mut router = Router::new();
429
430        for webhook in webhooks {
431            let auth = Arc::new(webhook.auth);
432            let handler = webhook.handler;
433            let path = webhook.path.clone();
434
435            let name: Arc<str> = Arc::from(path.as_str());
436            let route_state = WebhookState {
437                auth,
438                handler,
439                name,
440                tracker: handler_tracker.clone(),
441            };
442
443            router = router.route(&path, post(webhook_handler).with_state(route_state));
444            info!(path = %path, "registered webhook");
445        }
446
447        router = router.route("/health", get(|| async { "ok" }));
448
449        #[cfg(feature = "prometheus")]
450        if let Some(handle) = prom_handle {
451            router = router.route(
452                "/metrics",
453                get(move || {
454                    let h = handle.clone();
455                    async move { h.render() }
456                }),
457            );
458            info!("registered /metrics endpoint");
459        }
460
461        router
462            .layer(middleware::from_fn(security_headers))
463            .layer(DefaultBodyLimit::max(max_body_size))
464    }
465
466    /// Consumes the runtime and returns only the axum [`Router`].
467    ///
468    /// Cron jobs are **not** started. This is useful for testing the HTTP
469    /// layer in isolation without side-effects (e.g. with
470    /// `tower::ServiceExt::oneshot`).
471    ///
472    /// # Examples
473    ///
474    /// ```no_run
475    /// use ironflow_runtime::prelude::*;
476    ///
477    /// let router = Runtime::new()
478    ///     .webhook("/hooks/test", WebhookAuth::none(), |_payload| async {})
479    ///     .into_router();
480    /// ```
481    pub fn into_router(self) -> Router {
482        if !self.crons.is_empty() {
483            warn!(
484                cron_count = self.crons.len(),
485                "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
486            );
487        }
488        let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
489        Self::build_router(
490            self.webhooks,
491            tracker,
492            self.max_body_size,
493            #[cfg(feature = "prometheus")]
494            None,
495        )
496    }
497
498    /// Starts the cron scheduler with all registered cron jobs.
499    ///
500    /// This is an internal helper used by both [`Runtime::serve`] and
501    /// [`Runtime::run_crons`].
502    async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
503        let scheduler = JobScheduler::new().await?;
504
505        for cron_job in crons {
506            let handler = Arc::new(cron_job.handler);
507            let name = cron_job.name.clone();
508            let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
509            let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
510                let handler = handler.clone();
511                let name = name.clone();
512                let running = running.clone();
513                Box::pin(async move {
514                    if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
515                        warn!(cron = %name, "cron job still running, skipping this tick");
516                        return;
517                    }
518                    info!(cron = %name, "cron job triggered");
519                    #[cfg(feature = "prometheus")]
520                    metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
521                        .increment(1);
522                    (handler)().await;
523                    running.store(false, std::sync::atomic::Ordering::Release);
524                })
525            })?;
526            info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
527            scheduler.add(job).await?;
528        }
529
530        scheduler.start().await?;
531        Ok(scheduler)
532    }
533
534    /// Starts only the cron scheduler, blocking until a shutdown signal is
535    /// received (`Ctrl+C` / `SIGTERM`).
536    ///
537    /// Unlike [`Runtime::serve`], this does **not** start an HTTP server. Any
538    /// registered webhooks are ignored (a warning is logged if webhooks were
539    /// registered).
540    ///
541    /// # Errors
542    ///
543    /// Returns an error if:
544    ///
545    /// - The cron scheduler fails to initialise or a cron expression is invalid.
546    /// - The scheduler fails to shut down cleanly.
547    ///
548    /// # Examples
549    ///
550    /// ```no_run
551    /// use ironflow_runtime::prelude::*;
552    ///
553    /// #[tokio::main]
554    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
555    ///     Runtime::new()
556    ///         .cron("0 0 * * * *", "hourly-sync", || async {
557    ///             println!("syncing...");
558    ///         })
559    ///         .run_crons()
560    ///         .await?;
561    ///     Ok(())
562    /// }
563    /// ```
564    pub async fn run_crons(self) -> Result<(), RuntimeError> {
565        let _ = dotenvy::dotenv();
566
567        if !self.webhooks.is_empty() {
568            warn!(
569                webhook_count = self.webhooks.len(),
570                "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
571            );
572        }
573
574        #[cfg(feature = "prometheus")]
575        {
576            match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
577                Ok(_) => info!("prometheus metrics recorder installed"),
578                Err(_) => {
579                    info!("prometheus metrics recorder already installed, reusing existing")
580                }
581            }
582        }
583
584        let mut scheduler = Self::start_scheduler(self.crons).await?;
585
586        info!("ironflow cron scheduler running (no HTTP server)");
587        match self.custom_shutdown {
588            Some(signal) => signal.await,
589            None => shutdown_signal().await,
590        }
591
592        info!("shutting down scheduler");
593        scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
594        info!("ironflow cron scheduler stopped");
595
596        Ok(())
597    }
598
599    /// Starts the HTTP server and cron scheduler, blocking until shutdown.
600    ///
601    /// This method:
602    ///
603    /// 1. Loads environment variables from `.env` via [`dotenvy`].
604    /// 2. Starts the [`tokio_cron_scheduler`] scheduler with all registered cron jobs.
605    /// 3. Builds an [Axum](https://docs.rs/axum) router with all registered webhook
606    ///    routes plus a `GET /health` endpoint.
607    /// 4. Binds to `addr` and serves until a `Ctrl+C` signal is received.
608    /// 5. Gracefully shuts down the scheduler before returning.
609    ///
610    /// If you only need cron jobs without an HTTP server, use
611    /// [`Runtime::run_crons`] instead.
612    ///
613    /// # Errors
614    ///
615    /// Returns an error if:
616    ///
617    /// - The cron scheduler fails to initialise or a cron expression is invalid.
618    /// - The TCP listener cannot bind to `addr`.
619    /// - The Axum server encounters a fatal I/O error.
620    ///
621    /// # Examples
622    ///
623    /// ```no_run
624    /// use ironflow_runtime::prelude::*;
625    ///
626    /// #[tokio::main]
627    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
628    ///     Runtime::new()
629    ///         .serve("127.0.0.1:3000")
630    ///         .await?;
631    ///     Ok(())
632    /// }
633    /// ```
634    pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
635        let _ = dotenvy::dotenv();
636
637        #[cfg(feature = "prometheus")]
638        let prom_handle = {
639            match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
640                Ok(handle) => {
641                    info!("prometheus metrics recorder installed");
642                    Some(handle)
643                }
644                Err(_) => {
645                    info!("prometheus metrics recorder already installed, reusing existing");
646                    None
647                }
648            }
649        };
650
651        let mut scheduler = Self::start_scheduler(self.crons).await?;
652
653        // Start triggers
654        let trigger_token = CancellationToken::new();
655        let trigger_handles =
656            Self::start_triggers(self.triggers, self.trigger_handler, trigger_token.clone());
657
658        let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
659        let router = Self::build_router(
660            self.webhooks,
661            tracker.clone(),
662            self.max_body_size,
663            #[cfg(feature = "prometheus")]
664            prom_handle,
665        );
666
667        let listener = tokio::net::TcpListener::bind(addr)
668            .await
669            .map_err(RuntimeError::Bind)?;
670        info!(addr = %addr, "ironflow runtime listening");
671
672        let graceful_shutdown = match self.custom_shutdown {
673            Some(signal) => signal,
674            None => Box::pin(shutdown_signal()),
675        };
676        axum::serve(listener, router)
677            .with_graceful_shutdown(graceful_shutdown)
678            .await
679            .map_err(RuntimeError::Serve)?;
680
681        // Wait for all in-flight webhook handlers to finish.
682        info!("waiting for in-flight webhook handlers to complete");
683        tracker.wait().await;
684
685        // Stop triggers
686        info!("stopping triggers");
687        trigger_token.cancel();
688        for handle in trigger_handles {
689            if let Err(e) = handle.await {
690                error!(error = %e, "trigger task panicked");
691            }
692        }
693
694        info!("shutting down scheduler");
695        scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
696        info!("ironflow runtime stopped");
697
698        Ok(())
699    }
700
701    /// Start all registered triggers and the event-dispatch loop.
702    ///
703    /// Returns handles for each trigger task and the dispatch task.
704    fn start_triggers(
705        triggers: Vec<Box<dyn Trigger>>,
706        handler: Option<TriggerHandler>,
707        token: CancellationToken,
708    ) -> Vec<tokio::task::JoinHandle<()>> {
709        if triggers.is_empty() {
710            return Vec::new();
711        }
712
713        let (sink, mut rx) = TriggerSink::channel(DEFAULT_TRIGGER_CHANNEL_SIZE);
714        let mut handles = Vec::new();
715
716        for trigger in triggers {
717            let sink = sink.clone();
718            let token = token.clone();
719            let name = trigger.name().to_string();
720            let handle = tokio::spawn(async move {
721                info!(trigger = %name, "starting trigger");
722                if let Err(e) = trigger.start(sink, &token).await {
723                    error!(trigger = %name, error = %e, "trigger failed");
724                }
725                info!(trigger = %name, "trigger stopped");
726            });
727            handles.push(handle);
728        }
729
730        // Dispatch loop: forward trigger events to the handler.
731        if let Some(handler) = handler {
732            let dispatch_token = token.clone();
733            let dispatch_handle = tokio::spawn(async move {
734                loop {
735                    tokio::select! {
736                        _ = dispatch_token.cancelled() => break,
737                        event = rx.recv() => {
738                            let Some(event) = event else { break };
739                            info!(
740                                workflow = %event.workflow_name,
741                                "trigger event received, dispatching"
742                            );
743                            handler(event).await;
744                        }
745                    }
746                }
747            });
748            handles.push(dispatch_handle);
749        } else if !handles.is_empty() {
750            warn!(
751                "triggers registered but no on_trigger handler set - trigger events will be dropped"
752            );
753        }
754
755        handles
756    }
757}
758
759impl Default for Runtime {
760    fn default() -> Self {
761        Self::new()
762    }
763}
764
765/// Tracks in-flight webhook handlers and enforces a concurrency limit.
766///
767/// Combines a [`Semaphore`] for backpressure with a [`JoinSet`] so that
768/// [`Runtime::serve`] can wait for all running handlers before exiting.
769struct HandlerTracker {
770    semaphore: Arc<Semaphore>,
771    join_set: Mutex<JoinSet<()>>,
772}
773
774impl HandlerTracker {
775    fn new(max_concurrent: usize) -> Self {
776        Self {
777            semaphore: Arc::new(Semaphore::new(max_concurrent)),
778            join_set: Mutex::new(JoinSet::new()),
779        }
780    }
781
782    /// Spawn a handler task, respecting the concurrency limit.
783    async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
784        let semaphore = self.semaphore.clone();
785        let mut js = self.join_set.lock().await;
786        // Reap completed tasks to detect panics early.
787        while let Some(result) = js.try_join_next() {
788            if let Err(e) = result {
789                error!(error = %e, "webhook handler panicked");
790            }
791        }
792        use tracing::Instrument;
793        let span = tracing::info_span!("webhook", path = %name);
794        js.spawn(
795            async move {
796                let _permit = semaphore
797                    .acquire()
798                    .await
799                    .expect("semaphore closed unexpectedly");
800                info!("webhook workflow started");
801                handler(ctx).await;
802                info!("webhook workflow completed");
803            }
804            .instrument(span),
805        );
806    }
807
808    /// Wait for all in-flight handlers to complete.
809    async fn wait(&self) {
810        let mut js = self.join_set.lock().await;
811        while let Some(result) = js.join_next().await {
812            if let Err(e) = result {
813                error!(error = %e, "webhook handler panicked");
814            }
815        }
816    }
817}
818
819#[derive(Clone)]
820struct WebhookState {
821    auth: Arc<WebhookAuth>,
822    handler: WebhookHandler,
823    name: Arc<str>,
824    tracker: Arc<HandlerTracker>,
825}
826
827async fn webhook_handler(
828    State(state): State<WebhookState>,
829    headers: HeaderMap,
830    body: Bytes,
831) -> StatusCode {
832    let name = &state.name;
833    if !state.auth.verify(&headers, &body) {
834        warn!(webhook = %name, "webhook auth failed");
835        #[cfg(feature = "prometheus")]
836        {
837            let label: String = name.to_string();
838            metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
839        }
840        return StatusCode::UNAUTHORIZED;
841    }
842
843    let payload: Value = match from_slice(&body) {
844        Ok(v) => v,
845        Err(e) => {
846            warn!(webhook = %name, error = %e, "invalid JSON body");
847            #[cfg(feature = "prometheus")]
848            {
849                let label: String = name.to_string();
850                metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
851            }
852            return StatusCode::BAD_REQUEST;
853        }
854    };
855
856    #[cfg(feature = "prometheus")]
857    {
858        let label: String = name.to_string();
859        metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
860    }
861
862    let ctx = WebhookContext {
863        payload,
864        delivery_id: extract_delivery_id(&headers),
865    };
866
867    state
868        .tracker
869        .spawn(name.to_string(), state.handler.clone(), ctx)
870        .await;
871
872    StatusCode::ACCEPTED
873}
874
875async fn security_headers(
876    request: axum::http::Request<axum::body::Body>,
877    next: axum::middleware::Next,
878) -> axum::response::Response {
879    let mut response = next.run(request).await;
880    let headers = response.headers_mut();
881    headers.insert(
882        header::X_CONTENT_TYPE_OPTIONS,
883        "nosniff".parse().expect("valid header value"),
884    );
885    headers.insert(
886        header::X_FRAME_OPTIONS,
887        "DENY".parse().expect("valid header value"),
888    );
889    headers.insert(
890        "x-xss-protection",
891        "1; mode=block".parse().expect("valid header value"),
892    );
893    headers.insert(
894        header::STRICT_TRANSPORT_SECURITY,
895        "max-age=31536000; includeSubDomains"
896            .parse()
897            .expect("valid header value"),
898    );
899    headers.insert(
900        header::CONTENT_SECURITY_POLICY,
901        "default-src 'none'".parse().expect("valid header value"),
902    );
903    response
904}
905
906async fn shutdown_signal() {
907    let ctrl_c = async {
908        if let Err(e) = tokio::signal::ctrl_c().await {
909            warn!("failed to install ctrl+c handler: {e}");
910        }
911    };
912
913    #[cfg(unix)]
914    {
915        use tokio::signal::unix::{SignalKind, signal};
916        let mut sigterm =
917            signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
918        tokio::select! {
919            () = ctrl_c => info!("received SIGINT, shutting down"),
920            _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
921        }
922    }
923
924    #[cfg(not(unix))]
925    {
926        ctrl_c.await;
927        info!("received ctrl+c, shutting down");
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    /// Test that Runtime::new() creates a runtime with default values.
936    #[test]
937    fn runtime_new_creates_with_defaults() {
938        let rt = Runtime::new();
939        assert_eq!(rt.webhooks.len(), 0);
940        assert_eq!(rt.crons.len(), 0);
941        assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
942        assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
943        assert!(rt.custom_shutdown.is_none());
944    }
945
946    /// Test that Runtime::default() is equivalent to Runtime::new().
947    #[test]
948    fn runtime_default_equals_new() {
949        let rt_new = Runtime::new();
950        let rt_default = Runtime::default();
951        assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
952        assert_eq!(rt_new.crons.len(), rt_default.crons.len());
953        assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
954        assert_eq!(
955            rt_new.max_concurrent_handlers,
956            rt_default.max_concurrent_handlers
957        );
958    }
959
960    /// Test that max_body_size() builder method sets the value and returns self.
961    #[test]
962    fn max_body_size_sets_value_and_returns_self() {
963        let rt = Runtime::new().max_body_size(512 * 1024);
964        assert_eq!(rt.max_body_size, 512 * 1024);
965    }
966
967    /// Test that max_body_size() can be chained with other builder methods.
968    #[test]
969    fn max_body_size_chainable() {
970        let rt =
971            Runtime::new()
972                .max_body_size(1024)
973                .webhook("/test", WebhookAuth::none(), |_| async {});
974        assert_eq!(rt.max_body_size, 1024);
975        assert_eq!(rt.webhooks.len(), 1);
976    }
977
978    /// Test that max_body_size() can be set to zero.
979    #[test]
980    fn max_body_size_can_be_zero() {
981        let rt = Runtime::new().max_body_size(0);
982        assert_eq!(rt.max_body_size, 0);
983    }
984
985    /// Test that max_body_size() can be set to large values.
986    #[test]
987    fn max_body_size_can_be_large() {
988        let large_size = 1024 * 1024 * 1024; // 1 GiB
989        let rt = Runtime::new().max_body_size(large_size);
990        assert_eq!(rt.max_body_size, large_size);
991    }
992
993    /// Test that max_concurrent_handlers() panics when given 0.
994    #[test]
995    #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
996    fn max_concurrent_handlers_zero_panics() {
997        let _ = Runtime::new().max_concurrent_handlers(0);
998    }
999
1000    /// Test that max_concurrent_handlers() sets the value for valid inputs.
1001    #[test]
1002    fn max_concurrent_handlers_sets_valid_values() {
1003        let rt = Runtime::new().max_concurrent_handlers(16);
1004        assert_eq!(rt.max_concurrent_handlers, 16);
1005    }
1006
1007    /// Test that max_concurrent_handlers() with 1 is allowed.
1008    #[test]
1009    fn max_concurrent_handlers_one_is_valid() {
1010        let rt = Runtime::new().max_concurrent_handlers(1);
1011        assert_eq!(rt.max_concurrent_handlers, 1);
1012    }
1013
1014    /// Test that max_concurrent_handlers() with large values is allowed.
1015    #[test]
1016    fn max_concurrent_handlers_large_value_is_valid() {
1017        let large_limit = 10000;
1018        let rt = Runtime::new().max_concurrent_handlers(large_limit);
1019        assert_eq!(rt.max_concurrent_handlers, large_limit);
1020    }
1021
1022    /// Test that max_concurrent_handlers() returns self for chaining.
1023    #[test]
1024    fn max_concurrent_handlers_chainable() {
1025        let rt = Runtime::new().max_concurrent_handlers(32).webhook(
1026            "/test",
1027            WebhookAuth::none(),
1028            |_| async {},
1029        );
1030        assert_eq!(rt.max_concurrent_handlers, 32);
1031        assert_eq!(rt.webhooks.len(), 1);
1032    }
1033
1034    /// Test that with_shutdown() sets a custom shutdown signal and returns self.
1035    #[tokio::test]
1036    async fn with_shutdown_sets_signal_and_returns_self() {
1037        let (tx, rx) = tokio::sync::oneshot::channel();
1038        let rt = Runtime::new().with_shutdown(async move {
1039            let _ = rx.await;
1040        });
1041        assert!(rt.custom_shutdown.is_some());
1042
1043        // Signal to verify it was set properly.
1044        let _ = tx.send(());
1045    }
1046
1047    /// Test that with_shutdown() is chainable.
1048    #[tokio::test]
1049    async fn with_shutdown_chainable() {
1050        let (tx, rx) = tokio::sync::oneshot::channel();
1051        let rt = Runtime::new()
1052            .with_shutdown(async move {
1053                let _ = rx.await;
1054            })
1055            .webhook("/test", WebhookAuth::none(), |_| async {});
1056        assert!(rt.custom_shutdown.is_some());
1057        assert_eq!(rt.webhooks.len(), 1);
1058
1059        let _ = tx.send(());
1060    }
1061
1062    /// Test that webhook() registers a route and returns self.
1063    #[test]
1064    fn webhook_registers_route_and_returns_self() {
1065        let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
1066        assert_eq!(rt.webhooks.len(), 1);
1067        assert_eq!(rt.webhooks[0].path, "/hooks/test");
1068    }
1069
1070    /// Test that webhook() panics if path does not start with '/'.
1071    #[test]
1072    #[should_panic(expected = "webhook path must start with '/'")]
1073    fn webhook_path_without_slash_panics() {
1074        let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
1075    }
1076
1077    /// Test that webhook() accepts paths with various formats.
1078    #[test]
1079    fn webhook_accepts_valid_paths() {
1080        let rt = Runtime::new()
1081            .webhook("/", WebhookAuth::none(), |_| async {})
1082            .webhook("/simple", WebhookAuth::none(), |_| async {})
1083            .webhook("/nested/path", WebhookAuth::none(), |_| async {})
1084            .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
1085            .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
1086            .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
1087        assert_eq!(rt.webhooks.len(), 6);
1088    }
1089
1090    /// Test that webhook() can be chained multiple times.
1091    #[test]
1092    fn webhook_chainable() {
1093        let rt = Runtime::new()
1094            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1095            .webhook("/hook-b", WebhookAuth::none(), |_| async {})
1096            .webhook("/hook-c", WebhookAuth::none(), |_| async {});
1097        assert_eq!(rt.webhooks.len(), 3);
1098        assert_eq!(rt.webhooks[0].path, "/hook-a");
1099        assert_eq!(rt.webhooks[1].path, "/hook-b");
1100        assert_eq!(rt.webhooks[2].path, "/hook-c");
1101    }
1102
1103    /// Test that webhook() works with different auth types.
1104    #[test]
1105    fn webhook_with_various_auth_types() {
1106        let rt = Runtime::new()
1107            .webhook("/none", WebhookAuth::none(), |_| async {})
1108            .webhook(
1109                "/header",
1110                WebhookAuth::header("x-api-key", "secret"),
1111                |_| async {},
1112            )
1113            .webhook("/github", WebhookAuth::github("secret"), |_| async {})
1114            .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
1115        assert_eq!(rt.webhooks.len(), 4);
1116    }
1117
1118    /// Test that cron() registers a job and returns self.
1119    #[test]
1120    fn cron_registers_job_and_returns_self() {
1121        let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
1122        assert_eq!(rt.crons.len(), 1);
1123        assert_eq!(rt.crons[0].name, "daily-task");
1124        assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
1125    }
1126
1127    /// Test that cron() is chainable.
1128    #[test]
1129    fn cron_chainable() {
1130        let rt = Runtime::new()
1131            .cron("0 0 * * * *", "midnight", || async {})
1132            .cron("0 */5 * * * *", "every-5-minutes", || async {});
1133        assert_eq!(rt.crons.len(), 2);
1134    }
1135
1136    /// Test that cron() preserves all parameters correctly.
1137    #[test]
1138    fn cron_preserves_schedule_and_name() {
1139        let rt = Runtime::new()
1140            .cron("0 12 * * * MON", "noon-mondays", || async {})
1141            .cron("0 0 1 * * *", "first-of-month", || async {});
1142        assert_eq!(rt.crons[0].name, "noon-mondays");
1143        assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
1144        assert_eq!(rt.crons[1].name, "first-of-month");
1145        assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
1146    }
1147
1148    /// Test that into_router() returns a Router (compiles and doesn't panic).
1149    #[test]
1150    fn into_router_returns_router() {
1151        let rt = Runtime::new();
1152        let _router = rt.into_router();
1153        // If this compiles and doesn't panic, the router was successfully created.
1154    }
1155
1156    /// Test that into_router() with webhooks returns a Router.
1157    #[test]
1158    fn into_router_with_webhooks_returns_router() {
1159        let rt = Runtime::new()
1160            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1161            .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1162        let _router = rt.into_router();
1163        // If this compiles and doesn't panic, the router was successfully created with all webhooks.
1164    }
1165
1166    /// Test that into_router() with cron jobs (warns but doesn't panic).
1167    #[test]
1168    fn into_router_with_crons_returns_router() {
1169        let rt = Runtime::new()
1170            .cron("0 0 * * * *", "daily", || async {})
1171            .cron("0 */5 * * * *", "every-5-min", || async {});
1172        let _router = rt.into_router();
1173        // Crons are dropped but not an error; router should still be created.
1174    }
1175
1176    /// Test that into_router() with max_body_size returns a Router.
1177    #[test]
1178    fn into_router_respects_max_body_size_config() {
1179        let rt =
1180            Runtime::new()
1181                .max_body_size(100)
1182                .webhook("/hook", WebhookAuth::none(), |_| async {});
1183        let _router = rt.into_router();
1184        // Router created; actual body size limit enforcement is tested in integration tests.
1185    }
1186
1187    /// Test that into_router() with max_concurrent_handlers returns a Router.
1188    #[test]
1189    fn into_router_respects_max_concurrent_handlers_config() {
1190        let rt = Runtime::new().max_concurrent_handlers(16).webhook(
1191            "/hook",
1192            WebhookAuth::none(),
1193            |_| async {},
1194        );
1195        let _router = rt.into_router();
1196        // Router created; concurrency limit enforcement is tested in integration tests.
1197    }
1198
1199    /// Test full builder chain with multiple methods.
1200    #[test]
1201    fn builder_chain_multiple_methods() {
1202        let rt = Runtime::new()
1203            .max_body_size(512 * 1024)
1204            .max_concurrent_handlers(32)
1205            .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1206            .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
1207            .cron("0 0 * * * *", "daily", || async {});
1208
1209        assert_eq!(rt.max_body_size, 512 * 1024);
1210        assert_eq!(rt.max_concurrent_handlers, 32);
1211        assert_eq!(rt.webhooks.len(), 2);
1212        assert_eq!(rt.crons.len(), 1);
1213    }
1214
1215    /// Test that into_router() drops cron jobs with a warning logged.
1216    #[test]
1217    fn into_router_with_crons_doesnt_start_them() {
1218        let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1219        // This should not panic; crons are simply dropped.
1220        let _router = rt.into_router();
1221    }
1222}