1use chrono::{DateTime, Utc};
10use serde::Serialize;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12use std::{sync::Arc, time::Duration};
13use tokio::{sync::oneshot, task::JoinHandle};
14use url::Url;
15use uuid::Uuid;
16
17pub const MANIFEST_VERSION: u32 = 2;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
25#[non_exhaustive]
26pub struct AppManifest {
27 pub app_version: Option<String>,
29 pub git_sha: Option<String>,
31 pub jobs: Vec<String>,
33 pub critical_jobs: Vec<String>,
38 pub crons: Vec<CronEntry>,
40 pub base_url: Option<String>,
42 pub monitors: Option<Vec<HttpMonitor>>,
45 pub process_instance_id: Option<Uuid>,
46 pub process_role: Option<String>,
47 pub expected_process_roles: Option<Vec<ExpectedProcessRole>>,
48}
49
50impl AppManifest {
51 pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
52 self.app_version = Some(app_version.into());
53 self
54 }
55
56 pub fn git_sha(mut self, git_sha: impl Into<String>) -> Self {
57 self.git_sha = Some(git_sha.into());
58 self
59 }
60
61 pub fn jobs(mut self, jobs: Vec<String>) -> Self {
62 self.jobs = jobs;
63 self
64 }
65
66 pub fn critical_jobs(mut self, jobs: Vec<String>) -> Self {
70 self.critical_jobs = jobs;
71 self
72 }
73
74 pub fn crons(mut self, crons: Vec<CronEntry>) -> Self {
75 self.crons = crons;
76 self
77 }
78
79 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
80 self.base_url = Some(base_url.into());
81 self
82 }
83
84 pub fn monitors(mut self, monitors: Vec<HttpMonitor>) -> Self {
85 self.monitors = Some(monitors);
86 self
87 }
88 pub fn process(mut self, identity: ProcessIdentity) -> Self {
89 self.process_instance_id = Some(identity.instance_id());
90 self.process_role = Some(identity.role().to_owned());
91 self
92 }
93 pub fn expected_process_roles(mut self, roles: Vec<ExpectedProcessRole>) -> Self {
94 self.expected_process_roles = Some(roles);
95 self
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ProcessIdentity(Arc<ProcessIdentityInner>);
101#[derive(Debug, PartialEq, Eq)]
102struct ProcessIdentityInner {
103 instance_id: Uuid,
104 role: String,
105}
106impl ProcessIdentity {
107 pub fn new(role: impl Into<String>) -> Self {
108 Self(Arc::new(ProcessIdentityInner {
109 instance_id: Uuid::new_v4(),
110 role: role.into(),
111 }))
112 }
113 pub fn instance_id(&self) -> Uuid {
114 self.0.instance_id
115 }
116 pub fn role(&self) -> &str {
117 &self.0.role
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct ExpectedProcessRole {
123 pub role: String,
124 pub min_instances: u32,
125 pub heartbeat_interval_seconds: u64,
126 pub max_staleness_seconds: u64,
127 pub evaluation_interval_seconds: u64,
128 pub failure_threshold: u32,
129 pub shutdown_grace_seconds: u64,
130 pub enabled: bool,
131}
132impl ExpectedProcessRole {
133 pub fn new(role: impl Into<String>) -> Self {
134 Self {
135 role: role.into(),
136 min_instances: 1,
137 heartbeat_interval_seconds: 30,
138 max_staleness_seconds: 120,
139 evaluation_interval_seconds: 30,
140 failure_threshold: 4,
141 shutdown_grace_seconds: 120,
142 enabled: true,
143 }
144 }
145 pub fn min_instances(mut self, v: u32) -> Self {
146 self.min_instances = v;
147 self
148 }
149 pub fn heartbeat_interval_seconds(mut self, v: u64) -> Self {
150 self.heartbeat_interval_seconds = v;
151 self
152 }
153 pub fn max_staleness_seconds(mut self, v: u64) -> Self {
154 self.max_staleness_seconds = v;
155 self
156 }
157 pub fn evaluation_interval_seconds(mut self, v: u64) -> Self {
158 self.evaluation_interval_seconds = v;
159 self
160 }
161 pub fn failure_threshold(mut self, v: u32) -> Self {
162 self.failure_threshold = v;
163 self
164 }
165 pub fn shutdown_grace_seconds(mut self, v: u64) -> Self {
166 self.shutdown_grace_seconds = v;
167 self
168 }
169 pub fn enabled(mut self, v: bool) -> Self {
170 self.enabled = v;
171 self
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "UPPERCASE")]
177pub enum HttpMethod {
178 Get,
179 Head,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183pub struct HttpMonitor {
184 pub id: String,
185 pub target: String,
186 pub method: HttpMethod,
187 pub interval_seconds: u64,
188 pub timeout_seconds: u64,
189 pub expected_status_min: u16,
190 pub expected_status_max: u16,
191 pub failure_threshold: u32,
192 pub enabled: bool,
193}
194
195impl HttpMonitor {
196 pub fn new(id: impl Into<String>, target: impl Into<String>) -> Self {
197 Self {
198 id: id.into(),
199 target: target.into(),
200 method: HttpMethod::Get,
201 interval_seconds: 60,
202 timeout_seconds: 10,
203 expected_status_min: 200,
204 expected_status_max: 299,
205 failure_threshold: 3,
206 enabled: true,
207 }
208 }
209
210 pub fn method(mut self, method: HttpMethod) -> Self {
211 self.method = method;
212 self
213 }
214 pub fn interval_seconds(mut self, seconds: u64) -> Self {
215 self.interval_seconds = seconds;
216 self
217 }
218 pub fn timeout_seconds(mut self, seconds: u64) -> Self {
219 self.timeout_seconds = seconds;
220 self
221 }
222 pub fn expected_status(mut self, min: u16, max: u16) -> Self {
223 self.expected_status_min = min;
224 self.expected_status_max = max;
225 self
226 }
227 pub fn failure_threshold(mut self, threshold: u32) -> Self {
228 self.failure_threshold = threshold;
229 self
230 }
231 pub fn enabled(mut self, enabled: bool) -> Self {
232 self.enabled = enabled;
233 self
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
238pub enum MonitorTargetError {
239 #[error("relative monitor target requires base_url")]
240 MissingBaseUrl,
241 #[error("monitor target must be an absolute URL or root-relative path")]
242 NonRootRelative,
243 #[error("network-path monitor targets are not allowed")]
244 NetworkPath,
245 #[error("invalid monitor target URL: {0}")]
246 InvalidTarget(String),
247 #[error("invalid monitor base URL: {0}")]
248 InvalidBase(String),
249 #[error("only HTTP and HTTPS monitor URLs are supported")]
250 UnsupportedScheme,
251 #[error("monitor URLs may not contain credentials")]
252 Credentials,
253 #[error("monitor URLs may not contain fragments")]
254 Fragment,
255 #[error("monitor URL must contain a host")]
256 MissingHost,
257 #[error("localhost monitor targets are not allowed")]
258 Localhost,
259 #[error("monitor target uses a forbidden IP address")]
260 ForbiddenIp,
261}
262
263pub fn resolve_monitor_target(
265 base_url: Option<&str>,
266 target: &str,
267) -> Result<Url, MonitorTargetError> {
268 if target.starts_with("//") {
269 return Err(MonitorTargetError::NetworkPath);
270 }
271 let url = if target.starts_with('/') {
272 if target
276 .bytes()
277 .any(|byte| matches!(byte, b'\\' | b'\t' | b'\r' | b'\n'))
278 {
279 return Err(MonitorTargetError::NetworkPath);
280 }
281 let base = base_url.ok_or(MonitorTargetError::MissingBaseUrl)?;
282 let parsed =
283 Url::parse(base).map_err(|e| MonitorTargetError::InvalidBase(e.to_string()))?;
284 validate_url(&parsed).map_err(|error| match error {
285 MonitorTargetError::InvalidTarget(message) => MonitorTargetError::InvalidBase(message),
286 other => other,
287 })?;
288 let joined = parsed
289 .join(target)
290 .map_err(|e| MonitorTargetError::InvalidTarget(e.to_string()))?;
291 if monitor_origin(&joined)? != monitor_origin(&parsed)? {
292 return Err(MonitorTargetError::NetworkPath);
293 }
294 joined
295 } else {
296 Url::parse(target).map_err(|e| {
297 if e == url::ParseError::RelativeUrlWithoutBase {
298 MonitorTargetError::NonRootRelative
299 } else {
300 MonitorTargetError::InvalidTarget(e.to_string())
301 }
302 })?
303 };
304 validate_url(&url)?;
305 Ok(url)
306}
307
308pub fn monitor_origin(url: &Url) -> Result<String, MonitorTargetError> {
313 validate_url(url)?;
314 let host = match url.host().ok_or(MonitorTargetError::MissingHost)? {
315 url::Host::Domain(name) => name.trim_end_matches('.').to_ascii_lowercase(),
316 url::Host::Ipv4(ip) => ip.to_string(),
317 url::Host::Ipv6(ip) => format!("[{ip}]"),
318 };
319 let port = match (url.scheme(), url.port()) {
320 ("http", Some(80)) | ("https", Some(443)) | (_, None) => String::new(),
321 (_, Some(port)) => format!(":{port}"),
322 };
323 Ok(format!("{}://{host}{port}", url.scheme()))
324}
325
326fn validate_url(url: &Url) -> Result<(), MonitorTargetError> {
327 if !matches!(url.scheme(), "http" | "https") {
328 return Err(MonitorTargetError::UnsupportedScheme);
329 }
330 if !url.username().is_empty() || url.password().is_some() {
331 return Err(MonitorTargetError::Credentials);
332 }
333 if url.fragment().is_some() {
334 return Err(MonitorTargetError::Fragment);
335 }
336 let host = url.host().ok_or(MonitorTargetError::MissingHost)?;
337 match host {
338 url::Host::Domain(name)
339 if name.trim_end_matches('.').eq_ignore_ascii_case("localhost")
340 || name
341 .trim_end_matches('.')
342 .to_ascii_lowercase()
343 .ends_with(".localhost") =>
344 {
345 Err(MonitorTargetError::Localhost)
346 }
347 url::Host::Ipv4(ip) if is_forbidden_monitor_ip(ip.into()) => {
348 Err(MonitorTargetError::ForbiddenIp)
349 }
350 url::Host::Ipv6(ip) if is_forbidden_monitor_ip(ip.into()) => {
351 Err(MonitorTargetError::ForbiddenIp)
352 }
353 _ => Ok(()),
354 }
355}
356
357pub fn is_forbidden_monitor_ip(ip: IpAddr) -> bool {
360 match ip {
361 IpAddr::V4(ip) => forbidden_v4(ip),
362 IpAddr::V6(ip) => forbidden_v6(ip),
363 }
364}
365
366fn forbidden_v4(ip: Ipv4Addr) -> bool {
367 let value = u32::from(ip);
368 ip.is_unspecified()
369 || ip.is_loopback()
370 || ip.is_private()
371 || ip.is_link_local()
372 || ip.is_multicast()
373 || ip.is_broadcast()
374 || ip.is_documentation()
375 || value >> 24 == 0
376 || value & 0xffc0_0000 == 0x6440_0000 || value & 0xffff_ff00 == 0xc000_0000 || value & 0xffff_ff00 == 0xc058_6300 || value & 0xfffe_0000 == 0xc612_0000 || value & 0xf000_0000 == 0xf000_0000 }
382
383fn forbidden_v6(ip: Ipv6Addr) -> bool {
384 let octets = ip.octets();
385 let compatible_v4 = octets[..12]
386 .iter()
387 .all(|byte| *byte == 0)
388 .then(|| Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]));
389 ip.is_unspecified()
390 || ip.is_loopback()
391 || ip.is_multicast()
392 || ip.is_unique_local()
393 || ip.is_unicast_link_local()
394 || ip.to_ipv4_mapped().is_some_and(forbidden_v4)
395 || compatible_v4.is_some_and(forbidden_v4)
396 || octets[..12] == [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0] || (ip.segments()[0] == 0x0064 && ip.segments()[1] == 0xff9b
401 && ip.segments()[2] == 0x0001) || (ip.segments()[0] == 0x0100 && ip.segments()[1..4] == [0, 0, 0]) || (ip.segments()[0] == 0x2001 && ip.segments()[1] < 0x0200) || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8) || ip.segments()[0] == 0x2002 || (ip.segments()[0] == 0x3fff && (ip.segments()[1] & 0xf000) == 0) || ip.segments()[0] == 0x5f00 || (ip.segments()[0] & 0xffc0) == 0xfec0 || (ip.segments()[0] & 0xe000) != 0x2000 }
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
414pub struct CronEntry {
415 pub name: String,
416 pub schedule: String,
419}
420
421#[derive(Debug, thiserror::Error)]
423pub enum ManifestError {
424 #[error("Configuration error: {0}")]
426 Configuration(String),
427 #[error("HTTP request failed: {0}")]
429 Request(#[from] reqwest::Error),
430 #[error("Server returned error status: {0}")]
432 Status(reqwest::StatusCode),
433}
434
435#[derive(Debug, Serialize)]
440struct ManifestPayload<'a> {
441 manifest_version: u32,
442 app_version: Option<&'a str>,
443 git_sha: Option<&'a str>,
444 jobs: Vec<JobPayload<'a>>,
445 crons: &'a [CronEntry],
446 base_url: Option<&'a str>,
447 monitors: Option<&'a [HttpMonitor]>,
448 process_instance_id: Option<Uuid>,
449 process_role: Option<&'a str>,
450 expected_process_roles: Option<&'a [ExpectedProcessRole]>,
451 booted_at: DateTime<Utc>,
452}
453
454#[derive(Debug, Serialize)]
455struct JobPayload<'a> {
456 name: &'a str,
457 critical: bool,
458}
459
460impl<'a> ManifestPayload<'a> {
461 fn new(manifest: &'a AppManifest, booted_at: DateTime<Utc>) -> Self {
462 let critical: std::collections::HashSet<&str> =
463 manifest.critical_jobs.iter().map(String::as_str).collect();
464 Self {
465 manifest_version: MANIFEST_VERSION,
466 app_version: manifest.app_version.as_deref(),
467 git_sha: manifest.git_sha.as_deref(),
468 jobs: manifest
469 .jobs
470 .iter()
471 .map(|name| JobPayload {
472 name,
473 critical: critical.contains(name.as_str()),
474 })
475 .collect(),
476 crons: &manifest.crons,
477 base_url: manifest.base_url.as_deref(),
478 monitors: manifest.monitors.as_deref(),
479 process_instance_id: manifest.process_instance_id,
480 process_role: manifest.process_role.as_deref(),
481 expected_process_roles: manifest.expected_process_roles.as_deref(),
482 booted_at,
483 }
484 }
485}
486
487pub async fn send_manifest(
506 base_url: &str,
507 org_id: Uuid,
508 app_id: Uuid,
509 manifest: &AppManifest,
510 auth_token: Option<&str>,
511) -> Result<(), ManifestError> {
512 let url = Url::parse(base_url)
513 .and_then(|base| base.join(&format!("/api/orgs/{}/apps/{}/manifest", org_id, app_id)))
514 .map_err(|e| ManifestError::Configuration(format!("Invalid URL: {}", e)))?;
515
516 let payload = ManifestPayload::new(manifest, Utc::now());
517
518 let mut request = reqwest::Client::new().post(url).json(&payload);
519 if let Some(token) = auth_token {
520 request = request.bearer_auth(token);
521 }
522 let response = request.send().await?;
523
524 if !response.status().is_success() {
525 return Err(ManifestError::Status(response.status()));
526 }
527
528 Ok(())
529}
530
531pub async fn send_manifest_from_env(manifest: &AppManifest) -> Result<(), ManifestError> {
543 let base_url =
544 std::env::var("EYES_URL").unwrap_or_else(|_| "https://eyes.coreyja.com".to_string());
545
546 let org_id = uuid_from_env("EYES_ORG_ID")?;
547 let app_id = uuid_from_env("EYES_APP_ID")?;
548 let token = token_from_env();
549
550 send_manifest(&base_url, org_id, app_id, manifest, token.as_deref()).await
551}
552
553fn token_from_env() -> Option<String> {
555 std::env::var("EYES_TOKEN")
556 .ok()
557 .map(|s| s.trim().to_string())
558 .filter(|s| !s.is_empty())
559}
560
561fn uuid_from_env(var: &str) -> Result<Uuid, ManifestError> {
562 let value = std::env::var(var)
563 .map_err(|_| ManifestError::Configuration(format!("{var} must be set")))?;
564 Uuid::parse_str(value.trim())
565 .map_err(|e| ManifestError::Configuration(format!("{var} is not a valid UUID: {e}")))
566}
567
568#[derive(Debug, Clone, Serialize)]
569pub struct ProcessSignalPayload {
570 pub role: String,
571 pub app_version: Option<String>,
572 pub git_sha: Option<String>,
573}
574#[derive(Debug, thiserror::Error)]
575pub enum ProcessSignalError {
576 #[error("Configuration error: {0}")]
577 Configuration(String),
578 #[error("HTTP request failed: {0}")]
579 Request(#[from] reqwest::Error),
580 #[error("Server returned error status: {0}")]
581 Status(reqwest::StatusCode),
582}
583#[derive(Clone, Copy)]
591pub struct ProcessSignal<'a> {
592 pub base_url: &'a Url,
593 pub org_id: Uuid,
594 pub app_id: Uuid,
595 pub identity: &'a ProcessIdentity,
596 pub app_version: Option<&'a str>,
597 pub git_sha: Option<&'a str>,
598 pub auth_token: Option<&'a str>,
599}
600
601impl std::fmt::Debug for ProcessSignal<'_> {
603 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604 f.debug_struct("ProcessSignal")
605 .field("base_url", &self.base_url)
606 .field("org_id", &self.org_id)
607 .field("app_id", &self.app_id)
608 .field("identity", &self.identity)
609 .field("app_version", &self.app_version)
610 .field("git_sha", &self.git_sha)
611 .field("auth_token", &crate::RedactedToken(self.auth_token))
612 .finish()
613 }
614}
615
616impl ProcessSignal<'_> {
617 async fn send(
618 &self,
619 client: &reqwest::Client,
620 endpoint: &str,
621 ) -> Result<(), ProcessSignalError> {
622 let url = self
623 .base_url
624 .join(&format!(
625 "/api/orgs/{}/apps/{}/process-instances/{}/{endpoint}",
626 self.org_id,
627 self.app_id,
628 self.identity.instance_id()
629 ))
630 .map_err(|e| ProcessSignalError::Configuration(e.to_string()))?;
631 let mut request = client.post(url).json(&ProcessSignalPayload {
632 role: self.identity.role().to_owned(),
633 app_version: self.app_version.map(str::to_owned),
634 git_sha: self.git_sha.map(str::to_owned),
635 });
636 if let Some(token) = self.auth_token {
637 request = request.bearer_auth(token);
638 }
639 let response = request.send().await?;
640 if !response.status().is_success() {
641 return Err(ProcessSignalError::Status(response.status()));
642 }
643 Ok(())
644 }
645}
646
647pub async fn send_process_heartbeat(
648 client: &reqwest::Client,
649 signal: ProcessSignal<'_>,
650) -> Result<(), ProcessSignalError> {
651 signal.send(client, "heartbeat").await
652}
653
654pub async fn send_process_shutdown(
655 client: &reqwest::Client,
656 signal: ProcessSignal<'_>,
657) -> Result<(), ProcessSignalError> {
658 signal.send(client, "shutdown").await
659}
660
661#[derive(Clone)]
662pub struct ProcessHeartbeatConfig {
663 pub base_url: Url,
664 pub org_id: Uuid,
665 pub app_id: Uuid,
666 pub identity: ProcessIdentity,
667 pub app_version: Option<String>,
668 pub git_sha: Option<String>,
669 pub heartbeat_interval: Duration,
670 pub request_timeout: Duration,
671 pub shutdown_timeout: Duration,
672 pub token: Option<String>,
675}
676
677impl std::fmt::Debug for ProcessHeartbeatConfig {
679 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680 f.debug_struct("ProcessHeartbeatConfig")
681 .field("base_url", &self.base_url)
682 .field("org_id", &self.org_id)
683 .field("app_id", &self.app_id)
684 .field("identity", &self.identity)
685 .field("app_version", &self.app_version)
686 .field("git_sha", &self.git_sha)
687 .field("heartbeat_interval", &self.heartbeat_interval)
688 .field("request_timeout", &self.request_timeout)
689 .field("shutdown_timeout", &self.shutdown_timeout)
690 .field("token", &crate::RedactedToken(self.token.as_deref()))
691 .finish()
692 }
693}
694
695impl ProcessHeartbeatConfig {
696 pub fn new(
697 base_url: Url,
698 org_id: Uuid,
699 app_id: Uuid,
700 identity: ProcessIdentity,
701 heartbeat_interval: Duration,
702 ) -> Self {
703 let request_timeout = Duration::from_secs(10).min(heartbeat_interval / 2);
704 Self {
705 base_url,
706 org_id,
707 app_id,
708 identity,
709 app_version: None,
710 git_sha: None,
711 heartbeat_interval,
712 request_timeout,
713 shutdown_timeout: Duration::from_secs(5),
714 token: token_from_env(),
715 }
716 }
717
718 pub fn with_token(mut self, token: impl Into<String>) -> Self {
720 self.token = Some(token.into());
721 self
722 }
723
724 pub fn signal(&self) -> ProcessSignal<'_> {
727 ProcessSignal {
728 base_url: &self.base_url,
729 org_id: self.org_id,
730 app_id: self.app_id,
731 identity: &self.identity,
732 app_version: self.app_version.as_deref(),
733 git_sha: self.git_sha.as_deref(),
734 auth_token: self.token.as_deref(),
735 }
736 }
737 pub fn from_manifest(
738 base_url: Url,
739 org_id: Uuid,
740 app_id: Uuid,
741 manifest: &AppManifest,
742 ) -> Result<Self, ProcessSignalError> {
743 let id = manifest.process_instance_id.ok_or_else(|| {
744 ProcessSignalError::Configuration("manifest process identity is required".into())
745 })?;
746 let role = manifest.process_role.clone().ok_or_else(|| {
747 ProcessSignalError::Configuration("manifest process role is required".into())
748 })?;
749 let matches: Vec<_> = manifest
750 .expected_process_roles
751 .as_deref()
752 .unwrap_or_default()
753 .iter()
754 .filter(|r| r.enabled && r.role == role)
755 .collect();
756 if matches.len() != 1 {
757 return Err(ProcessSignalError::Configuration(
758 "exactly one enabled declaration must match the process role".into(),
759 ));
760 }
761 let mut c = Self::new(
762 base_url,
763 org_id,
764 app_id,
765 ProcessIdentity(Arc::new(ProcessIdentityInner {
766 instance_id: id,
767 role,
768 })),
769 Duration::from_secs(matches[0].heartbeat_interval_seconds),
770 );
771 c.app_version = manifest.app_version.clone();
772 c.git_sha = manifest.git_sha.clone();
773 c.validate()?;
774 Ok(c)
775 }
776 fn validate(&self) -> Result<(), ProcessSignalError> {
777 if self.heartbeat_interval.is_zero()
778 || self.request_timeout.is_zero()
779 || self.request_timeout >= self.heartbeat_interval
780 {
781 return Err(ProcessSignalError::Configuration(
782 "invalid heartbeat interval or request timeout".into(),
783 ));
784 }
785 Ok(())
786 }
787}
788pub struct ProcessHeartbeatHandle {
789 cancel: oneshot::Sender<()>,
790 task: JoinHandle<()>,
791 config: ProcessHeartbeatConfig,
792 client: reqwest::Client,
793}
794pub struct ProcessHeartbeat;
795impl ProcessHeartbeat {
796 pub fn spawn(
797 config: ProcessHeartbeatConfig,
798 ) -> Result<ProcessHeartbeatHandle, ProcessSignalError> {
799 config.validate()?;
800 let client = reqwest::Client::builder()
801 .timeout(config.request_timeout)
802 .build()?;
803 let worker_client = client.clone();
804 let worker_config = config.clone();
805 let (cancel, mut cancellation) = oneshot::channel();
806 let task = tokio::spawn(async move {
807 let mut interval = tokio::time::interval(worker_config.heartbeat_interval);
808 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
809 loop {
810 tokio::select! {_=&mut cancellation=>break,_=interval.tick()=>{let result=send_process_heartbeat(&worker_client,worker_config.signal()).await;if let Err(error)=result{tracing::warn!(%error,"Eyes process heartbeat failed");}}}
811 }
812 });
813 Ok(ProcessHeartbeatHandle {
814 cancel,
815 task,
816 config,
817 client,
818 })
819 }
820}
821impl ProcessHeartbeatHandle {
822 pub async fn shutdown(self) -> Result<(), ProcessSignalError> {
823 let _ = self.cancel.send(());
824 self.task.abort();
825 let send = send_process_shutdown(&self.client, self.config.signal());
826 tokio::time::timeout(self.config.shutdown_timeout, send)
827 .await
828 .map_err(|_| {
829 ProcessSignalError::Configuration("process shutdown timed out".into())
830 })??;
831 Ok(())
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn test_manifest_payload_serialization_shape() {
841 let manifest = AppManifest::default()
842 .app_version("1.2.3")
843 .git_sha("abc123")
844 .jobs(vec!["SendEmail".to_string(), "RefreshCache".to_string()])
845 .critical_jobs(vec!["SendEmail".to_string(), "Unknown".to_string()])
846 .crons(vec![CronEntry {
847 name: "DailyDigest".to_string(),
848 schedule: "0 0 * * *".to_string(),
849 }]);
850
851 let booted_at = Utc::now();
852 let payload = ManifestPayload::new(&manifest, booted_at);
853 let json = serde_json::to_value(&payload).unwrap();
854
855 assert_eq!(
856 json,
857 serde_json::json!({
858 "manifest_version": 2,
859 "app_version": "1.2.3",
860 "git_sha": "abc123",
861 "jobs": [
862 { "name": "SendEmail", "critical": true },
863 { "name": "RefreshCache", "critical": false },
864 ],
865 "crons": [
866 { "name": "DailyDigest", "schedule": "0 0 * * *" },
867 ],
868 "base_url": null,
869 "monitors": null,
870 "process_instance_id": null,
871 "process_role": null,
872 "expected_process_roles": null,
873 "booted_at": serde_json::to_value(booted_at).unwrap(),
874 })
875 );
876 }
877
878 #[test]
879 fn test_empty_manifest_payload_serialization_shape() {
880 let manifest = AppManifest::default();
881 let booted_at = Utc::now();
882 let json = serde_json::to_value(ManifestPayload::new(&manifest, booted_at)).unwrap();
883
884 assert_eq!(json["manifest_version"], 2);
885 assert_eq!(json["app_version"], serde_json::Value::Null);
886 assert_eq!(json["git_sha"], serde_json::Value::Null);
887 assert_eq!(json["jobs"], serde_json::json!([]));
888 assert_eq!(json["crons"], serde_json::json!([]));
889 assert!(json["booted_at"].is_string());
890 }
891
892 #[test]
893 fn resolves_absolute_and_root_relative_targets() {
894 assert_eq!(
895 resolve_monitor_target(Some("https://example.com/app/"), "/health?full=1")
896 .unwrap()
897 .as_str(),
898 "https://example.com/health?full=1"
899 );
900 assert_eq!(
901 resolve_monitor_target(Some("https://example.com/app"), "/health")
902 .unwrap()
903 .as_str(),
904 "https://example.com/health"
905 );
906 assert_eq!(
907 resolve_monitor_target(None, "https://status.example.net/ping")
908 .unwrap()
909 .as_str(),
910 "https://status.example.net/ping"
911 );
912 }
913
914 #[test]
915 fn rejects_unsafe_or_ambiguous_targets() {
916 assert_eq!(
917 resolve_monitor_target(None, "/health"),
918 Err(MonitorTargetError::MissingBaseUrl)
919 );
920 assert_eq!(
921 resolve_monitor_target(None, "health"),
922 Err(MonitorTargetError::NonRootRelative)
923 );
924 assert_eq!(
925 resolve_monitor_target(Some("https://example.com"), "//evil.example"),
926 Err(MonitorTargetError::NetworkPath)
927 );
928 for target in [
929 "/\\evil.example/x",
930 "/\t/evil.example/x",
931 "/\n/evil.example/x",
932 "ftp://example.com/a",
933 "https://user@example.com/a",
934 "https://example.com/a#fragment",
935 "http://localhost/a",
936 "http://api.localhost/a",
937 "http://localhost./a",
938 "http://127.0.0.1/a",
939 "http://10.0.0.1/a",
940 "http://169.254.1.1/a",
941 "http://192.0.2.1/a",
942 "http://0.1.2.3/a",
943 "http://100.64.1.1/a",
944 "http://192.0.0.1/a",
945 "http://192.88.99.1/a",
946 "http://198.18.0.1/a",
947 "http://240.0.0.1/a",
948 "http://[::1]/a",
949 "http://[::7f00:1]/a",
950 "http://[fc00::1]/a",
951 "http://[2001:db8::1]/a",
952 "http://[fec0::1]/a",
953 "http://[64:ff9b::c000:201]/a",
954 "http://[3fff::1]/a",
955 "http://[5f00::1]/a",
956 ] {
957 assert!(
958 resolve_monitor_target(None, target).is_err(),
959 "accepted {target}"
960 );
961 }
962 for target in [
963 "https://1.1.1.1/a",
964 "https://8.8.8.8/a",
965 "https://[2606:4700:4700::1111]/a",
966 ] {
967 assert!(
968 resolve_monitor_target(None, target).is_ok(),
969 "rejected public target {target}"
970 );
971 }
972 }
973
974 #[test]
975 fn monitor_builder_serializes_stable_defaults() {
976 let monitor = HttpMonitor::new("public-health", "/health");
977 assert_eq!(
978 serde_json::to_value(monitor).unwrap(),
979 serde_json::json!({
980 "id": "public-health", "target": "/health", "method": "GET",
981 "interval_seconds": 60, "timeout_seconds": 10,
982 "expected_status_min": 200, "expected_status_max": 299,
983 "failure_threshold": 3, "enabled": true
984 })
985 );
986 }
987}