1use 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#[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#[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#[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")]
222pub 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"))]
242pub 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")]
284pub 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"))]
305pub 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
316pub fn send_manifest(manifest: AppManifest) {
337 spawn_send(manifest);
338}
339
340#[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#[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(®istry),
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}