Skip to main content

eyes_subscriber/
manifest.rs

1//! Boot-time app manifests.
2//!
3//! Telemetry shows what happened; the manifest tells Eyes what the app's
4//! *shape* is — every registered job type, every cron entry with its
5//! schedule, and the build version. Apps send one manifest at boot via
6//! [`send_manifest`] (or [`send_manifest_from_env`]); each stored manifest
7//! doubles as a boot/deploy marker on the server.
8
9use 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
17/// The manifest schema version this crate emits.
18pub const MANIFEST_VERSION: u32 = 2;
19
20/// The shape of the application, as reported once at boot.
21///
22/// `manifest_version` and `booted_at` are filled in internally when the
23/// manifest is sent ([`MANIFEST_VERSION`] and `Utc::now()` respectively).
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
25#[non_exhaustive]
26pub struct AppManifest {
27    /// Application build version (e.g. from `CARGO_PKG_VERSION`).
28    pub app_version: Option<String>,
29    /// Git commit SHA of the running build.
30    pub git_sha: Option<String>,
31    /// Names of every registered job type.
32    pub jobs: Vec<String>,
33    /// Client-only selector: which of `jobs` should be marked critical on the
34    /// wire. Names not also present in `jobs` are ignored; duplicates are
35    /// deduplicated. There is no second top-level wire field — see
36    /// [`ManifestPayload::new`].
37    pub critical_jobs: Vec<String>,
38    /// Every registered cron entry with its schedule.
39    pub crons: Vec<CronEntry>,
40    /// Public origin used to resolve root-relative monitor targets.
41    pub base_url: Option<String>,
42    /// `None` does not participate in monitor authority; `Some(vec![])`
43    /// explicitly removes every declaration for this app.
44    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    /// Mark a subset of `jobs` as critical (declares a `critical_job_failed`
67    /// run-health monitor for each, once cja adopts process identity). Names
68    /// not present in `jobs` are ignored; duplicates are deduplicated.
69    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
263/// Resolve and validate an absolute HTTP(S) URL or a root-relative path.
264pub 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        // WHATWG parsing treats backslashes as slashes for special schemes and
273        // strips ASCII tabs/newlines. Reject those spellings before Url::join
274        // can reinterpret a path as an authority.
275        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
308/// Return the normalized HTTP origin used for monitor security comparisons.
309///
310/// Domain names are lowercase without a trailing root dot and default ports
311/// are omitted, so equivalent DNS spellings cannot bypass origin checks.
312pub 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
357/// Returns true when an address is not an acceptable monitor egress target.
358/// This is shared by declaration validation and the connection-time resolver.
359pub 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 // 100.64.0.0/10
377        || value & 0xffff_ff00 == 0xc000_0000 // 192.0.0.0/24
378        || value & 0xffff_ff00 == 0xc058_6300 // 192.88.99.0/24
379        || value & 0xfffe_0000 == 0xc612_0000 // 198.18.0.0/15
380        || value & 0xf000_0000 == 0xf000_0000 // 240.0.0.0/4
381}
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        // Deny the IANA special-purpose blocks as groups. Some contain narrow
397        // protocol exceptions, but none are appropriate arbitrary HTTP egress
398        // targets and denying the containing allocation is the safer default.
399        || octets[..12] == [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0] // 64:ff9b::/96
400        || (ip.segments()[0] == 0x0064 && ip.segments()[1] == 0xff9b
401            && ip.segments()[2] == 0x0001) // 64:ff9b:1::/48
402        || (ip.segments()[0] == 0x0100 && ip.segments()[1..4] == [0, 0, 0]) // 100::/64
403        || (ip.segments()[0] == 0x2001 && ip.segments()[1] < 0x0200) // 2001::/23
404        || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8) // documentation /32
405        || ip.segments()[0] == 0x2002 // 6to4, whose embedded IPv4 may be private
406        || (ip.segments()[0] == 0x3fff && (ip.segments()[1] & 0xf000) == 0) // documentation /20
407        || ip.segments()[0] == 0x5f00 // segment-routing SIDs /16
408        || (ip.segments()[0] & 0xffc0) == 0xfec0 // deprecated site-local /10
409        || (ip.segments()[0] & 0xe000) != 0x2000 // outside global-unicast 2000::/3
410}
411
412/// A single cron registration: its name and schedule string.
413#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
414pub struct CronEntry {
415    pub name: String,
416    /// Human/cron schedule string as the app reports it
417    /// (e.g. "every 300s" or "0 0 * * *").
418    pub schedule: String,
419}
420
421/// Error sending a boot manifest.
422#[derive(Debug, thiserror::Error)]
423pub enum ManifestError {
424    /// Bad base URL or missing/invalid environment configuration.
425    #[error("Configuration error: {0}")]
426    Configuration(String),
427    /// The HTTP request itself failed (connect, timeout, ...).
428    #[error("HTTP request failed: {0}")]
429    Request(#[from] reqwest::Error),
430    /// The server answered with a non-2xx status.
431    #[error("Server returned error status: {0}")]
432    Status(reqwest::StatusCode),
433}
434
435/// Wire payload for `POST /api/orgs/:org_id/apps/:app_id/manifest`.
436///
437/// Jobs are serialized as `{"name": "..."}` objects (not bare strings) so
438/// per-job fields can be added later without a server migration.
439#[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
487/// Send a boot-time manifest to the Eyes server as a one-shot HTTP POST.
488///
489/// `booted_at` is stamped with `Utc::now()` at send time and
490/// `manifest_version` with [`MANIFEST_VERSION`]. There is no queueing,
491/// batching, or retrying involved — this is a single request.
492///
493/// Callers should fire-and-forget with a warning on failure: a manifest
494/// failure must never block app boot. For example:
495///
496/// ```no_run
497/// # use eyes_subscriber::AppManifest;
498/// # async fn example(base_url: &str, org_id: uuid::Uuid, app_id: uuid::Uuid) {
499/// let manifest = AppManifest::default();
500/// if let Err(e) = eyes_subscriber::send_manifest(base_url, org_id, app_id, &manifest, None).await {
501///     tracing::warn!("Failed to send app manifest to eyes: {e}");
502/// }
503/// # }
504/// ```
505pub 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
531/// [`send_manifest`], reading the destination from the environment:
532///
533/// - `EYES_URL`: base URL (defaults to `https://eyes.coreyja.com`, matching
534///   [`crate::EyesSubscriberBuilder`])
535/// - `EYES_ORG_ID`: org UUID (required)
536/// - `EYES_APP_ID`: app UUID (required)
537/// - `EYES_TOKEN`: bearer token (optional while the server runs in warn mode)
538///
539/// Returns [`ManifestError::Configuration`] if `EYES_ORG_ID`/`EYES_APP_ID`
540/// are unset or not valid UUIDs. As with [`send_manifest`], failures should
541/// be logged and ignored by callers — never block app boot on this.
542pub 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
553/// `EYES_TOKEN`, trimmed; empty is treated as absent.
554fn 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/// Everything a process signal (heartbeat or shutdown) needs besides the HTTP
584/// client.
585///
586/// A struct rather than eight positional parameters: adding `auth_token` to
587/// the old parameter list pushed both functions past clippy's
588/// `too_many_arguments` threshold, and these fields always travel together
589/// (they're all fields of [`ProcessHeartbeatConfig`]).
590#[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
601// Hand-written so the bearer token is never printed. See `crate::RedactedToken`.
602impl 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    /// Bearer token for the heartbeat/shutdown endpoints. Defaults to
673    /// `EYES_TOKEN`; override with [`ProcessHeartbeatConfig::with_token`].
674    pub token: Option<String>,
675}
676
677// Hand-written so the bearer token is never printed. See `crate::RedactedToken`.
678impl 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    /// Override the bearer token, which otherwise defaults to `EYES_TOKEN`.
719    pub fn with_token(mut self, token: impl Into<String>) -> Self {
720        self.token = Some(token.into());
721        self
722    }
723
724    /// Borrow this config as a [`ProcessSignal`] for
725    /// [`send_process_heartbeat`] / [`send_process_shutdown`].
726    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}