Skip to main content

cja/
eyes_manifest.rs

1//! Boot-time app manifest emission to Eyes.
2//!
3//! Telemetry shows what *happened*; the manifest tells Eyes what the app's
4//! *shape* is: registered jobs, cron entries, build metadata, and optionally
5//! HTTP monitors. Apps normally send one manifest at process start.
6//!
7//! Legacy emission is fire-and-forget: transport failures never fail boot, a
8//! missing Tokio runtime produces a warning, and missing `EYES_ORG_ID` or
9//! `EYES_APP_ID` makes sending a debug-logged no-op. State-aware builders add
10//! synchronous declaration validation. Legacy manifests use `monitors: None`
11//! (no monitor authority), while state-aware manifests use `Some`, including
12//! `Some([])` to authoritatively remove all monitors. The server-only rejection
13//! of a target equal to the Eyes origin remains an asynchronously logged error.
14//!
15//! # Intended call site
16//!
17//! Call once at boot after constructing job and cron registries:
18//!
19//! ```rust,ignore
20//! let _eyes_handle = cja::setup::setup_tracing("my-app")?;
21//! let app_state = AppState::from_env().await?;
22//! let cron_registry = cron_registry();
23//! cja::eyes_manifest::send_boot_manifest::<Jobs, AppState>(
24//!     Some(env!("CARGO_PKG_VERSION")),
25//!     option_env!("VERGEN_GIT_SHA"),
26//!     Some(&cron_registry),
27//! );
28//! ```
29//!
30//! `app_version` and `git_sha` are explicit because cja cannot read the
31//! application's compile-time environment. Pass `None` when unavailable.
32
33use std::collections::HashSet;
34
35use crate::{app_state::AppState, jobs::registry::JobRegistry};
36
37pub use eyes_subscriber::{
38    AppManifest, CronEntry, DashboardItem, DashboardLink, DashboardSection, ExpectedProcessRole,
39    HttpMethod, HttpMonitor, ManifestError, MetricThresholdBuilder, MonitorTargetError,
40    NamedDashboard, NamedMetric, NamedMetricBuilder, ProcessHeartbeat, ProcessHeartbeatConfig,
41    ProcessHeartbeatHandle, ProcessIdentity, ThresholdComparison,
42};
43
44/// A locally invalid HTTP monitor declaration.
45#[derive(Debug, thiserror::Error, PartialEq, Eq)]
46pub enum BootManifestDeclarationError {
47    #[error(transparent)]
48    Target(#[from] MonitorTargetError),
49    #[error("at most 100 HTTP monitors may be declared")]
50    TooManyMonitors,
51    #[error("monitor ID must not be empty")]
52    EmptyId,
53    #[error("monitor ID exceeds 128 bytes")]
54    IdTooLong,
55    #[error("monitor ID must already be trimmed")]
56    UntrimmedId,
57    #[error("monitor ID must be ASCII")]
58    NonAsciiId,
59    #[error("monitor ID contains an invalid character")]
60    InvalidIdCharacter,
61    #[error("duplicate monitor ID: {0}")]
62    DuplicateId(String),
63    #[error("monitor interval must be between 1 and i64::MAX seconds")]
64    InvalidInterval,
65    #[error("monitor timeout must be between 1 and i64::MAX seconds")]
66    InvalidTimeout,
67    #[error("monitor failure threshold must be between 1 and i32::MAX")]
68    InvalidFailureThreshold,
69    #[error("monitor timeout must not exceed its interval")]
70    TimeoutExceedsInterval,
71    #[error("monitor status bounds must be between 100 and 599")]
72    InvalidStatusBounds,
73    #[error("monitor minimum status must not exceed its maximum status")]
74    ReversedStatusBounds,
75}
76
77fn legacy_manifest<J, S>(
78    app_version: Option<&str>,
79    git_sha: Option<&str>,
80    crons: Vec<CronEntry>,
81) -> AppManifest
82where
83    J: JobRegistry<S>,
84    S: AppState,
85{
86    let manifest = AppManifest::default()
87        .jobs(J::job_names().iter().map(ToString::to_string).collect())
88        .crons(crons);
89    let manifest = if let Some(value) = app_version {
90        manifest.app_version(value)
91    } else {
92        manifest
93    };
94    if let Some(value) = git_sha {
95        manifest.git_sha(value)
96    } else {
97        manifest
98    }
99}
100
101/// Build an [`AppManifest`] from a job registry and (optionally) a cron registry.
102///
103/// Job names come from [`JobRegistry::job_names`] (generated by
104/// `impl_job_registry!`). Cron schedule strings come from
105/// [`crate::cron::CronRegistry::entries`]: the `Duration` `Debug` form for
106/// interval schedules (e.g. `"300s"`) and the original expression for cron
107/// expression schedules.
108///
109/// This does not participate in HTTP monitor authority; prefer it when
110/// monitors are managed elsewhere. Use [`build_boot_manifest_from_state`] to
111/// declare monitors from application state.
112///
113/// Most apps should call [`send_boot_manifest`] instead; this is exposed for
114/// callers that want to inspect or customize the manifest before sending it
115/// with [`send_manifest`] (e.g. attaching a `base_url` and [`HttpMonitor`]
116/// declarations).
117#[cfg(feature = "cron")]
118#[must_use]
119pub fn build_boot_manifest<J, S>(
120    app_version: Option<&str>,
121    git_sha: Option<&str>,
122    cron_registry: Option<&crate::cron::CronRegistry<S>>,
123) -> AppManifest
124where
125    J: JobRegistry<S>,
126    S: AppState,
127{
128    let crons = cron_registry
129        .map(|registry| {
130            registry
131                .entries()
132                .into_iter()
133                .map(|(name, schedule)| CronEntry {
134                    name: name.to_string(),
135                    schedule,
136                })
137                .collect()
138        })
139        .unwrap_or_default();
140    legacy_manifest::<J, S>(app_version, git_sha, crons)
141}
142
143/// Build an [`AppManifest`] from a job registry (without cron support).
144///
145/// This does not participate in HTTP monitor authority; prefer it when
146/// monitors are managed elsewhere. Use [`build_boot_manifest_from_state`] to
147/// declare monitors from application state.
148#[cfg(not(feature = "cron"))]
149#[must_use]
150pub fn build_boot_manifest<J, S>(app_version: Option<&str>, git_sha: Option<&str>) -> AppManifest
151where
152    J: JobRegistry<S>,
153    S: AppState,
154{
155    legacy_manifest::<J, S>(app_version, git_sha, Vec::new())
156}
157
158fn monitor_declarations<S: AppState>(
159    state: &S,
160) -> Result<(Option<String>, Vec<HttpMonitor>), BootManifestDeclarationError> {
161    let base = state.eyes_base_url().map(ToString::to_string);
162    if let Some(base) = base.as_deref() {
163        eyes_subscriber::resolve_monitor_target(None, base)?;
164    }
165    let mut monitors = state.eyes_http_monitors();
166    if monitors.len() > 100 {
167        return Err(BootManifestDeclarationError::TooManyMonitors);
168    }
169    let mut ids = HashSet::with_capacity(monitors.len());
170    for monitor in &mut monitors {
171        if monitor.id.is_empty() {
172            return Err(BootManifestDeclarationError::EmptyId);
173        }
174        if monitor.id.len() > 128 {
175            return Err(BootManifestDeclarationError::IdTooLong);
176        }
177        if monitor.id.trim() != monitor.id {
178            return Err(BootManifestDeclarationError::UntrimmedId);
179        }
180        if !monitor.id.is_ascii() {
181            return Err(BootManifestDeclarationError::NonAsciiId);
182        }
183        if !monitor
184            .id
185            .bytes()
186            .all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b))
187        {
188            return Err(BootManifestDeclarationError::InvalidIdCharacter);
189        }
190        if !ids.insert(monitor.id.clone()) {
191            return Err(BootManifestDeclarationError::DuplicateId(
192                monitor.id.clone(),
193            ));
194        }
195        if monitor.interval_seconds == 0 || monitor.interval_seconds > i64::MAX as u64 {
196            return Err(BootManifestDeclarationError::InvalidInterval);
197        }
198        if monitor.timeout_seconds == 0 || monitor.timeout_seconds > i64::MAX as u64 {
199            return Err(BootManifestDeclarationError::InvalidTimeout);
200        }
201        if monitor.failure_threshold == 0 || monitor.failure_threshold > i32::MAX as u32 {
202            return Err(BootManifestDeclarationError::InvalidFailureThreshold);
203        }
204        if monitor.timeout_seconds > monitor.interval_seconds {
205            return Err(BootManifestDeclarationError::TimeoutExceedsInterval);
206        }
207        if !(100..=599).contains(&monitor.expected_status_min)
208            || !(100..=599).contains(&monitor.expected_status_max)
209        {
210            return Err(BootManifestDeclarationError::InvalidStatusBounds);
211        }
212        if monitor.expected_status_min > monitor.expected_status_max {
213            return Err(BootManifestDeclarationError::ReversedStatusBounds);
214        }
215        monitor.target =
216            eyes_subscriber::resolve_monitor_target(base.as_deref(), &monitor.target)?.to_string();
217    }
218    Ok((base, monitors))
219}
220
221#[cfg(feature = "cron")]
222/// Builds and validates an authoritative manifest from application state.
223///
224/// An empty declaration list becomes `Some([])`. Locally detectable invalid
225/// declarations are returned synchronously. Rejection of the Eyes server's own
226/// origin requires server context and is reported asynchronously when sent.
227pub fn build_boot_manifest_from_state<J, S>(
228    state: &S,
229    app_version: Option<&str>,
230    git_sha: Option<&str>,
231    cron_registry: Option<&crate::cron::CronRegistry<S>>,
232) -> Result<AppManifest, BootManifestDeclarationError>
233where
234    J: JobRegistry<S>,
235    S: AppState,
236{
237    let manifest = build_boot_manifest::<J, S>(app_version, git_sha, cron_registry);
238    finish_state_manifest(state, manifest)
239}
240
241#[cfg(not(feature = "cron"))]
242/// Builds and validates an authoritative manifest from application state.
243///
244/// An empty declaration list becomes `Some([])`. Locally detectable invalid
245/// declarations are returned synchronously. Rejection of the Eyes server's own
246/// origin requires server context and is reported asynchronously when sent.
247pub fn build_boot_manifest_from_state<J, S>(
248    state: &S,
249    app_version: Option<&str>,
250    git_sha: Option<&str>,
251) -> Result<AppManifest, BootManifestDeclarationError>
252where
253    J: JobRegistry<S>,
254    S: AppState,
255{
256    let manifest = build_boot_manifest::<J, S>(app_version, git_sha);
257    finish_state_manifest(state, manifest)
258}
259
260fn finish_state_manifest<S: AppState>(
261    state: &S,
262    manifest: AppManifest,
263) -> Result<AppManifest, BootManifestDeclarationError> {
264    let (base, monitors) = monitor_declarations(state)?;
265    let manifest = if let Some(base) = base {
266        manifest.base_url(base)
267    } else {
268        manifest
269    };
270    Ok(manifest.monitors(monitors))
271}
272
273fn eyes_configured(org_id: Option<&str>, app_id: Option<&str>) -> bool {
274    org_id.is_some() && app_id.is_some()
275}
276fn eyes_configured_from_env() -> bool {
277    eyes_configured(
278        std::env::var("EYES_ORG_ID").ok().as_deref(),
279        std::env::var("EYES_APP_ID").ok().as_deref(),
280    )
281}
282
283#[cfg(feature = "cron")]
284/// Sends a legacy boot manifest to Eyes, fire-and-forget.
285///
286/// Missing configuration, a missing Tokio runtime, and transport/server errors
287/// are logged and never fail application boot. See the [module docs](self) for
288/// the intended call site and build metadata guidance.
289pub fn send_boot_manifest<J, S>(
290    app_version: Option<&str>,
291    git_sha: Option<&str>,
292    cron_registry: Option<&crate::cron::CronRegistry<S>>,
293) where
294    J: JobRegistry<S>,
295    S: AppState,
296{
297    spawn_send(build_boot_manifest::<J, S>(
298        app_version,
299        git_sha,
300        cron_registry,
301    ));
302}
303
304#[cfg(not(feature = "cron"))]
305/// Sends a legacy boot manifest to Eyes, fire-and-forget (without cron support).
306///
307/// See the cron-enabled variant for configuration and failure behavior.
308pub fn send_boot_manifest<J, S>(app_version: Option<&str>, git_sha: Option<&str>)
309where
310    J: JobRegistry<S>,
311    S: AppState,
312{
313    spawn_send(build_boot_manifest::<J, S>(app_version, git_sha));
314}
315
316/// Send a caller-built manifest to Eyes, fire-and-forget.
317///
318/// Like [`send_boot_manifest`], but for apps that need declarations the
319/// registries can't express. Build the base manifest with
320/// [`build_boot_manifest`], attach the extra declarations with the
321/// [`AppManifest`] builder methods, then send:
322///
323/// ```rust,ignore
324/// let manifest = cja::eyes_manifest::build_boot_manifest::<Jobs, AppState>(
325///     Some(env!("CARGO_PKG_VERSION")),
326///     option_env!("VERGEN_GIT_SHA"),
327///     Some(&cron_registry),
328/// )
329/// .base_url("https://arena.battlesnake.com")
330/// .monitors(vec![HttpMonitor::new("health", "/health")]);
331/// cja::eyes_manifest::send_manifest(manifest);
332/// ```
333///
334/// Same guarantees as [`send_boot_manifest`]: never blocks or fails app boot,
335/// debug-logged no-op when `EYES_ORG_ID`/`EYES_APP_ID` are unset.
336pub fn send_manifest(manifest: AppManifest) {
337    spawn_send(manifest);
338}
339
340/// Sends an authoritative state-aware manifest. Use `::<Jobs, _>(&app_state, ...)`.
341///
342/// # Errors
343/// Returns a declaration error only when Eyes is configured. Callers should
344/// generally log and continue rather than use `?` during boot unless missing
345/// monitoring should make the application unavailable.
346#[cfg(feature = "cron")]
347pub fn send_boot_manifest_from_state<J, S>(
348    state: &S,
349    app_version: Option<&str>,
350    git_sha: Option<&str>,
351    cron_registry: Option<&crate::cron::CronRegistry<S>>,
352) -> Result<(), BootManifestDeclarationError>
353where
354    J: JobRegistry<S>,
355    S: AppState,
356{
357    send_boot_manifest_from_state_inner::<J, S>(
358        eyes_configured_from_env(),
359        state,
360        app_version,
361        git_sha,
362        cron_registry,
363    )
364}
365
366#[cfg(feature = "cron")]
367fn send_boot_manifest_from_state_inner<J, S>(
368    configured: bool,
369    state: &S,
370    app_version: Option<&str>,
371    git_sha: Option<&str>,
372    cron_registry: Option<&crate::cron::CronRegistry<S>>,
373) -> Result<(), BootManifestDeclarationError>
374where
375    J: JobRegistry<S>,
376    S: AppState,
377{
378    if configured {
379        spawn_send(build_boot_manifest_from_state::<J, S>(
380            state,
381            app_version,
382            git_sha,
383            cron_registry,
384        )?);
385    }
386    Ok(())
387}
388
389/// Sends an authoritative state-aware manifest. Use `::<Jobs, _>(&app_state, ...)`.
390///
391/// # Errors
392/// Returns a declaration error only when Eyes is configured. Callers should
393/// generally log and continue rather than use `?` during boot unless missing
394/// monitoring should make the application unavailable.
395#[cfg(not(feature = "cron"))]
396pub fn send_boot_manifest_from_state<J, S>(
397    state: &S,
398    app_version: Option<&str>,
399    git_sha: Option<&str>,
400) -> Result<(), BootManifestDeclarationError>
401where
402    J: JobRegistry<S>,
403    S: AppState,
404{
405    send_boot_manifest_from_state_inner::<J, S>(
406        eyes_configured_from_env(),
407        state,
408        app_version,
409        git_sha,
410    )
411}
412
413#[cfg(not(feature = "cron"))]
414fn send_boot_manifest_from_state_inner<J, S>(
415    configured: bool,
416    state: &S,
417    app_version: Option<&str>,
418    git_sha: Option<&str>,
419) -> Result<(), BootManifestDeclarationError>
420where
421    J: JobRegistry<S>,
422    S: AppState,
423{
424    if configured {
425        spawn_send(build_boot_manifest_from_state::<J, S>(
426            state,
427            app_version,
428            git_sha,
429        )?);
430    }
431    Ok(())
432}
433
434fn spawn_send(manifest: AppManifest) {
435    if !eyes_configured_from_env() {
436        tracing::debug!("Skipping Eyes boot manifest: EYES_ORG_ID and/or EYES_APP_ID not set");
437        return;
438    }
439    if let Ok(handle) = tokio::runtime::Handle::try_current() {
440        handle.spawn(async move {
441            if let Err(error) = eyes_subscriber::send_manifest_from_env(&manifest).await {
442                tracing::warn!(%error, "Failed to send Eyes boot manifest");
443            }
444        });
445    } else {
446        tracing::warn!("Skipping Eyes boot manifest: no Tokio runtime available");
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::{impl_job_registry, jobs::Job, server::cookies::CookieKey};
454
455    #[derive(Clone)]
456    struct TestAppState {
457        db: sqlx::PgPool,
458        key: CookieKey,
459        base: Option<String>,
460        monitors: Vec<HttpMonitor>,
461        panic_on_monitor_hooks: bool,
462    }
463    impl AppState for TestAppState {
464        fn version(&self) -> &'static str {
465            "test"
466        }
467        fn db(&self) -> &sqlx::PgPool {
468            &self.db
469        }
470        fn cookie_key(&self) -> &CookieKey {
471            &self.key
472        }
473        fn eyes_base_url(&self) -> Option<&str> {
474            assert!(!self.panic_on_monitor_hooks, "base hook must not be called");
475            self.base.as_deref()
476        }
477        fn eyes_http_monitors(&self) -> Vec<HttpMonitor> {
478            assert!(
479                !self.panic_on_monitor_hooks,
480                "monitor hook must not be called"
481            );
482            self.monitors.clone()
483        }
484    }
485    #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
486    struct ManifestJobA;
487    #[async_trait::async_trait]
488    impl Job<TestAppState> for ManifestJobA {
489        const NAME: &'static str = "ManifestJobA";
490        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
491            Ok(())
492        }
493    }
494
495    #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
496    struct ManifestJobB;
497
498    #[async_trait::async_trait]
499    impl Job<TestAppState> for ManifestJobB {
500        const NAME: &'static str = "ManifestJobB";
501
502        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
503            Ok(())
504        }
505    }
506
507    impl_job_registry!(TestAppState, ManifestJobA, ManifestJobB);
508    fn test_state(base: Option<&str>, monitors: Vec<HttpMonitor>) -> TestAppState {
509        TestAppState {
510            db: sqlx::PgPool::connect_lazy("postgres://localhost/cja_test").unwrap(),
511            key: CookieKey::generate(),
512            base: base.map(str::to_string),
513            monitors,
514            panic_on_monitor_hooks: false,
515        }
516    }
517    #[cfg(feature = "cron")]
518    fn build_from_state(s: &TestAppState) -> Result<AppManifest, BootManifestDeclarationError> {
519        build_boot_manifest_from_state::<Jobs, TestAppState>(s, None, None, None)
520    }
521    #[cfg(not(feature = "cron"))]
522    fn build_from_state(s: &TestAppState) -> Result<AppManifest, BootManifestDeclarationError> {
523        build_boot_manifest_from_state::<Jobs, TestAppState>(s, None, None)
524    }
525
526    #[tokio::test]
527    async fn resolves_and_preserves_authority() {
528        let manifest = build_from_state(&test_state(
529            Some("https://example.com/app/"),
530            vec![HttpMonitor::new("health", "/health")],
531        ))
532        .unwrap();
533        assert_eq!(
534            manifest.base_url.as_deref(),
535            Some("https://example.com/app/")
536        );
537        assert_eq!(
538            manifest.monitors.unwrap()[0].target,
539            "https://example.com/health"
540        );
541        let without_trailing_slash = build_from_state(&test_state(
542            Some("https://example.com/app"),
543            vec![HttpMonitor::new("health", "/health")],
544        ))
545        .unwrap();
546        assert_eq!(
547            without_trailing_slash.monitors.unwrap()[0].target,
548            "https://example.com/health"
549        );
550        let empty = build_from_state(&test_state(None, vec![])).unwrap();
551        assert_eq!(empty.monitors, Some(vec![]));
552    }
553    #[test]
554    fn legacy_manifest_does_not_participate_in_monitor_authority() {
555        #[cfg(feature = "cron")]
556        let manifest = build_boot_manifest::<Jobs, TestAppState>(Some("1.2.3"), Some("abc"), None);
557        #[cfg(not(feature = "cron"))]
558        let manifest = build_boot_manifest::<Jobs, TestAppState>(Some("1.2.3"), Some("abc"));
559        assert_eq!(manifest.app_version.as_deref(), Some("1.2.3"));
560        assert_eq!(manifest.git_sha.as_deref(), Some("abc"));
561        assert_eq!(manifest.jobs, vec!["ManifestJobA", "ManifestJobB"]);
562        assert_eq!(manifest.base_url, None);
563        assert_eq!(manifest.monitors, None);
564    }
565
566    #[cfg(feature = "cron")]
567    #[test]
568    fn cron_manifest_preserves_schedules_and_build_metadata() {
569        use std::time::Duration;
570
571        let mut registry = crate::cron::CronRegistry::new();
572        registry.register_job(ManifestJobA, None, Duration::from_mins(5));
573        registry
574            .register_job_with_cron(ManifestJobB, None, "0 0 9 * * * *")
575            .unwrap();
576
577        let manifest = build_boot_manifest::<Jobs, TestAppState>(
578            Some("1.2.3"),
579            Some("abc123"),
580            Some(&registry),
581        );
582        assert_eq!(manifest.app_version.as_deref(), Some("1.2.3"));
583        assert_eq!(manifest.git_sha.as_deref(), Some("abc123"));
584        assert_eq!(manifest.jobs, vec!["ManifestJobA", "ManifestJobB"]);
585        assert_eq!(
586            manifest.crons,
587            vec![
588                CronEntry {
589                    name: "ManifestJobA".into(),
590                    schedule: "300s".into(),
591                },
592                CronEntry {
593                    name: "ManifestJobB".into(),
594                    schedule: "0 0 9 * * * *".into(),
595                },
596            ]
597        );
598    }
599    #[tokio::test]
600    async fn absolute_head_needs_no_base() {
601        let monitor = HttpMonitor::new("external", "https://status.example.net/ping")
602            .method(HttpMethod::Head)
603            .enabled(false);
604        assert_eq!(
605            build_from_state(&test_state(None, vec![monitor.clone()]))
606                .unwrap()
607                .monitors,
608            Some(vec![monitor])
609        );
610    }
611    #[tokio::test]
612    async fn validates_base_and_targets() {
613        for base in ["not a url", "ftp://example.com", "http://localhost:3000"] {
614            assert!(matches!(
615                build_from_state(&test_state(Some(base), vec![])),
616                Err(BootManifestDeclarationError::Target(_))
617            ));
618        }
619        for target in [
620            "/health",
621            "health",
622            "//evil.example",
623            "https://user@example.com",
624            "https://example.com/#x",
625            "ftp://example.com",
626            "http://127.0.0.1",
627        ] {
628            assert!(
629                matches!(
630                    build_from_state(&test_state(None, vec![HttpMonitor::new("x", target)])),
631                    Err(BootManifestDeclarationError::Target(_))
632                ),
633                "{target}"
634            );
635        }
636    }
637    #[tokio::test]
638    async fn validates_structure() {
639        let bad = [
640            (
641                HttpMonitor::new("", "https://example.com"),
642                BootManifestDeclarationError::EmptyId,
643            ),
644            (
645                HttpMonitor::new("x".repeat(129), "https://example.com"),
646                BootManifestDeclarationError::IdTooLong,
647            ),
648            (
649                HttpMonitor::new(" x", "https://example.com"),
650                BootManifestDeclarationError::UntrimmedId,
651            ),
652            (
653                HttpMonitor::new("x!", "https://example.com"),
654                BootManifestDeclarationError::InvalidIdCharacter,
655            ),
656            (
657                HttpMonitor::new("é", "https://example.com"),
658                BootManifestDeclarationError::NonAsciiId,
659            ),
660        ];
661        for (monitor, expected) in bad {
662            assert_eq!(
663                build_from_state(&test_state(None, vec![monitor])),
664                Err(expected)
665            );
666        }
667        let mut monitors = vec![HttpMonitor::new("x", "https://example.com"); 2];
668        assert!(matches!(
669            build_from_state(&test_state(None, monitors)),
670            Err(BootManifestDeclarationError::DuplicateId(_))
671        ));
672        monitors = (0..101)
673            .map(|i| HttpMonitor::new(i.to_string(), "https://example.com"))
674            .collect();
675        assert_eq!(
676            build_from_state(&test_state(None, monitors)),
677            Err(BootManifestDeclarationError::TooManyMonitors)
678        );
679        let variants = [
680            (
681                HttpMonitor::new("x", "https://example.com").interval_seconds(0),
682                BootManifestDeclarationError::InvalidInterval,
683            ),
684            (
685                HttpMonitor::new("x", "https://example.com").interval_seconds(i64::MAX as u64 + 1),
686                BootManifestDeclarationError::InvalidInterval,
687            ),
688            (
689                HttpMonitor::new("x", "https://example.com").timeout_seconds(0),
690                BootManifestDeclarationError::InvalidTimeout,
691            ),
692            (
693                HttpMonitor::new("x", "https://example.com").timeout_seconds(i64::MAX as u64 + 1),
694                BootManifestDeclarationError::InvalidTimeout,
695            ),
696            (
697                HttpMonitor::new("x", "https://example.com").failure_threshold(0),
698                BootManifestDeclarationError::InvalidFailureThreshold,
699            ),
700            (
701                HttpMonitor::new("x", "https://example.com").failure_threshold(i32::MAX as u32 + 1),
702                BootManifestDeclarationError::InvalidFailureThreshold,
703            ),
704            (
705                HttpMonitor::new("x", "https://example.com")
706                    .interval_seconds(1)
707                    .timeout_seconds(2),
708                BootManifestDeclarationError::TimeoutExceedsInterval,
709            ),
710            (
711                HttpMonitor::new("x", "https://example.com").expected_status(99, 200),
712                BootManifestDeclarationError::InvalidStatusBounds,
713            ),
714            (
715                HttpMonitor::new("x", "https://example.com").expected_status(200, 600),
716                BootManifestDeclarationError::InvalidStatusBounds,
717            ),
718            (
719                HttpMonitor::new("x", "https://example.com").expected_status(300, 200),
720                BootManifestDeclarationError::ReversedStatusBounds,
721            ),
722        ];
723        for (monitor, expected) in variants {
724            assert_eq!(
725                build_from_state(&test_state(None, vec![monitor])),
726                Err(expected)
727            );
728        }
729    }
730
731    #[tokio::test]
732    async fn state_send_skips_hooks_when_unconfigured_and_validates_when_configured() {
733        let mut panicking = test_state(None, vec![]);
734        panicking.panic_on_monitor_hooks = true;
735        #[cfg(feature = "cron")]
736        let unconfigured = send_boot_manifest_from_state_inner::<Jobs, TestAppState>(
737            false, &panicking, None, None, None,
738        );
739        #[cfg(not(feature = "cron"))]
740        let unconfigured = send_boot_manifest_from_state_inner::<Jobs, TestAppState>(
741            false, &panicking, None, None,
742        );
743        assert_eq!(unconfigured, Ok(()));
744
745        let invalid = test_state(None, vec![HttpMonitor::new("", "/health")]);
746        #[cfg(feature = "cron")]
747        let configured = send_boot_manifest_from_state_inner::<Jobs, TestAppState>(
748            true, &invalid, None, None, None,
749        );
750        #[cfg(not(feature = "cron"))]
751        let configured =
752            send_boot_manifest_from_state_inner::<Jobs, TestAppState>(true, &invalid, None, None);
753        assert_eq!(configured, Err(BootManifestDeclarationError::EmptyId));
754    }
755    #[test]
756    fn configuration_presence() {
757        assert!(eyes_configured(Some(""), Some("")));
758        assert!(!eyes_configured(None, Some("x")));
759        assert!(!eyes_configured(Some("x"), None));
760    }
761}