1use std::collections::BTreeMap;
39
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42
43use crate::bench::{BenchmarkProjection, Coverage};
44use crate::capability::handler::{CapabilityError, Context};
45use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
46use crate::capability::module::ModuleDescriptor;
47use crate::capability::CachePolicy;
48use crate::generate;
49use crate::git::GitState;
50use crate::index::State;
51use crate::model::Severity;
52use crate::{capability, module};
53
54use super::server::{self, ServerStanding};
55use super::{get, Empty};
56
57pub const HEALTH_URI: &str = "majordomus://health";
59
60#[derive(
63 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum HealthStatus {
67 Ok,
69 Warn,
71 Fail,
73 Unknown,
75}
76
77impl HealthStatus {
78 pub fn as_str(self) -> &'static str {
85 match self {
86 HealthStatus::Ok => "ok",
87 HealthStatus::Warn => "warn",
88 HealthStatus::Fail => "fail",
89 HealthStatus::Unknown => "unknown",
90 }
91 }
92
93 pub fn worse(self, other: Self) -> Self {
102 if other > self {
103 other
104 } else {
105 self
106 }
107 }
108}
109
110pub const PROCESS_DIMENSIONS: [&str; 3] = ["server", "peers", "mesh"];
121
122pub fn repository_status(checks: &[HealthCheck]) -> HealthStatus {
140 checks
141 .iter()
142 .filter(|c| !PROCESS_DIMENSIONS.contains(&c.id.as_str()))
143 .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status))
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149pub struct HealthCheck {
150 pub id: String,
152 pub title: String,
154 pub status: HealthStatus,
156 pub detail: String,
158 pub decided_by: String,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub evidence: Vec<String>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub findings: Vec<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
171pub struct Health {
172 pub status: HealthStatus,
174 pub repository: HealthStatus,
181 pub tallies: BTreeMap<String, usize>,
183 pub checks: Vec<HealthCheck>,
185 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
190 pub observed: BTreeMap<String, crate::index::AnswerObservation>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Liveness {
198 pub alive: bool,
201 pub version: String,
203 pub commit: String,
208 pub dirty: Option<bool>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
217pub struct Readiness {
218 pub ready: bool,
220 pub version: String,
222 pub commit: String,
225 pub dirty: Option<bool>,
227 pub capabilities: usize,
229 pub objects: usize,
232 pub layer: HealthStatus,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub stale: Option<String>,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub reasons: Vec<String>,
247}
248
249fn liveness(_: &Context, _: Empty) -> Result<Liveness, CapabilityError> {
252 Ok(Liveness {
253 alive: true,
254 version: crate::VERSION.into(),
255 commit: crate::COMMIT.into(),
256 dirty: crate::DIRTY,
257 })
258}
259
260fn readiness(ctx: &Context, _: Empty) -> Result<Readiness, CapabilityError> {
264 let capabilities = ctx.registry.summary().total;
265 let objects = ctx.index.objects.len();
266 let stale = crate::lease::serving_replaced_code();
267 let lost = crate::lease::was_lost();
268 let mut reasons = Vec::new();
269 if lost {
270 reasons.push("lease_lost: another process holds this checkout's lease; this one serves the sessions it had and takes on nobody new".to_string());
271 }
272 if let Some(why) = &stale {
273 reasons.push(format!("stale: {why}"));
274 }
275 if ctx.index.state == State::Degraded {
276 reasons.push(format!(
277 "layer_degraded: the layer was read with {} diagnostic(s); it is served as read",
278 ctx.index.diagnostics.len()
279 ));
280 }
281 Ok(Readiness {
282 ready: capabilities > 0 && stale.is_none() && !lost,
283 reasons,
284 version: crate::VERSION.into(),
285 commit: crate::COMMIT.into(),
286 dirty: crate::DIRTY,
287 capabilities,
288 objects,
289 layer: match ctx.index.state {
290 State::Degraded => HealthStatus::Warn,
291 State::Ok => HealthStatus::Ok,
292 },
293 stale,
294 })
295}
296
297fn record(checks: &mut Vec<HealthCheck>, p: &crate::execution::Progress, check: HealthCheck) {
305 p.step_done(
306 &check.id,
307 check.status != HealthStatus::Fail,
308 Some(check.detail.clone()),
309 );
310 checks.push(check);
311 p.progress(checks.len() as u64, None, "dimension(s) decided");
312}
313
314fn peers_check(board: Option<&[crate::peers::Peer]>) -> HealthCheck {
319 let decided_by = "the peer board of the process holding this checkout's lease, tallied as `environment.preflight` tallies it";
320 let Some(peers) = board else {
321 return HealthCheck {
322 id: "peers".into(),
323 title: "Attached clients".into(),
324 status: HealthStatus::Unknown,
325 detail: "this process holds no peer board: it does not hold this checkout's lease, so who is attached is the server's to say".into(),
326 decided_by: decided_by.into(),
327 evidence: vec!["majordomus_peers".into(), "majordomus serve status".into()],
328 findings: Vec::new(),
329 };
330 };
331 let t = crate::environment::preflight::PeersObservation::of(peers);
332 let silent = t.attached - t.announced;
333 let mut findings = Vec::new();
334 if t.detached > 0 {
335 findings.push(format!(
336 "{} peer(s) are gone and the board still shows their claims",
337 t.detached
338 ));
339 }
340 if silent > 0 {
341 findings.push(format!(
342 "{silent} attached peer(s) have announced nothing, so the board understates who is here"
343 ));
344 }
345 HealthCheck {
346 id: "peers".into(),
347 title: "Attached clients".into(),
348 status: if findings.is_empty() {
349 HealthStatus::Ok
350 } else {
351 HealthStatus::Warn
352 },
353 detail: format!(
354 "{} attached ({} announced) · {} gone with claims still shown",
355 t.attached, t.announced, t.detached
356 ),
357 decided_by: decided_by.into(),
358 evidence: vec!["majordomus_peers".into()],
359 findings,
360 }
361}
362
363fn health(ctx: &Context, _: Empty) -> Result<Health, CapabilityError> {
364 let index = &ctx.index;
365 let mut checks = Vec::new();
366
367 let errors = index
369 .diagnostics
370 .iter()
371 .filter(|d| d.severity == Severity::Error)
372 .count();
373 let warnings = index
374 .diagnostics
375 .iter()
376 .filter(|d| d.severity == Severity::Warning)
377 .count();
378 record(
379 &mut checks,
380 &ctx.progress,
381 HealthCheck {
382 id: "layer".into(),
383 title: "The layer as it was read".into(),
384 status: match (index.state, warnings) {
385 (State::Degraded, _) => HealthStatus::Fail,
386 (State::Ok, 0) => HealthStatus::Ok,
387 (State::Ok, _) => HealthStatus::Warn,
388 },
389 detail: format!(
390 "{} object(s) indexed; {errors} error(s), {warnings} warning(s)",
391 index.objects.len()
392 ),
393 decided_by: "the index's own diagnostics".into(),
394 evidence: vec!["majordomus capabilities validate".into()],
395 findings: index
396 .diagnostics
397 .iter()
398 .filter(|d| d.severity != Severity::Info)
399 .map(|d| {
400 format!(
401 "{}: {} ({})",
402 d.code,
403 d.message,
404 d.path.as_deref().unwrap_or("-")
405 )
406 })
407 .collect(),
408 },
409 );
410
411 let summary = ctx.registry.summary();
414 record(
415 &mut checks,
416 &ctx.progress,
417 HealthCheck {
418 id: "registry".into(),
419 title: "The capability registry".into(),
420 status: HealthStatus::Ok,
421 detail: format!(
422 "{} capabilities ({} builtin, {} declarative) across {} modules; fingerprint {}",
423 summary.total,
424 summary.builtin,
425 summary.declarative,
426 summary.modules,
427 &ctx.registry.fingerprint()[..12.min(ctx.registry.fingerprint().len())]
428 ),
429 decided_by:
430 "the registry builder; it refuses to build on a duplicate or a malformed exposure"
431 .into(),
432 evidence: vec!["majordomus capabilities validate".into()],
433 findings: Vec::new(),
434 },
435 );
436
437 let tally = &index.scoped.tally;
439 record(
440 &mut checks,
441 &ctx.progress,
442 HealthCheck {
443 id: "scope".into(),
444 title: "The declared scope".into(),
445 status: HealthStatus::Ok,
446 detail: format!(
447 "{} of {} tracked file(s) in scope, {} out, read from {}",
448 tally.r#in, tally.files, tally.out, index.repository.scope_path
449 ),
450 decided_by: "the scope declaration the index was built under".into(),
451 evidence: vec!["majordomus scope".into()],
452 findings: Vec::new(),
453 },
454 );
455
456 let (git_status, git_detail) = match &index.repository.git {
458 GitState::Available(info) => {
459 let head = info.head.as_deref().unwrap_or("(unborn)");
460 (
461 if info.working_tree == "clean" {
462 HealthStatus::Ok
463 } else {
464 HealthStatus::Warn
465 },
466 format!(
467 "{} at {}, working tree {}",
468 info.branch.as_deref().unwrap_or("(detached)"),
469 &head[..12.min(head.len())],
470 info.working_tree
471 ),
472 )
473 }
474 GitState::Unavailable { reason } => (HealthStatus::Unknown, reason.clone()),
475 };
476 record(
477 &mut checks,
478 &ctx.progress,
479 HealthCheck {
480 id: "git".into(),
481 title: "Version control".into(),
482 status: git_status,
483 detail: git_detail,
484 decided_by: "git, as the index asked it when it was built".into(),
485 evidence: vec!["git status".into()],
486 findings: Vec::new(),
487 },
488 );
489
490 let projection = BenchmarkProjection::from_context(ctx);
492 let coverage = Coverage::compute(ctx, &projection);
493 let total = coverage.tallies.get("total").cloned().unwrap_or_default();
494 record(
495 &mut checks,
496 &ctx.progress,
497 HealthCheck {
498 id: "benchmark-coverage".into(),
499 title: "Benchmark coverage".into(),
500 status: if total.missing > 0 {
501 HealthStatus::Fail
502 } else if total.waived > 0 {
503 HealthStatus::Warn
504 } else {
505 HealthStatus::Ok
506 },
507 detail: format!(
508 "{} of {} target(s) covered, {} missing, {} waived",
509 total.covered, total.required, total.missing, total.waived
510 ),
511 decided_by:
512 "the benchmark projection's coverage, the same one `bench coverage --check` reads"
513 .into(),
514 evidence: vec!["majordomus bench coverage --check".into()],
515 findings: coverage
516 .lines
517 .iter()
518 .filter(|l| matches!(l.state, crate::bench::CoverageState::Missing))
519 .map(|l| format!("{} on {}: no case", l.subject, l.transport.name()))
520 .collect(),
521 },
522 );
523
524 let root = std::path::Path::new(&index.repository.root);
531 let manifest = generate::Document::new(
532 "registry",
533 generate::REGISTRY_SCHEMA,
534 "the canonical capability registry",
535 generate::registry_manifest(&ctx.registry),
536 )
537 .artifacts(crate::VERSION);
538 let manifest = manifest
539 .iter()
540 .find(|a| a.format == generate::ArtifactFormat::Json)
541 .map(|a| a.content.clone())
542 .unwrap_or_default();
543 let path = format!("{}/registry.json", generate::OUT_DIR);
544 let committed = std::fs::read_to_string(root.join(&path));
545 let (status, detail, findings) = match &committed {
546 Ok(text) if *text == manifest => (
547 HealthStatus::Ok,
548 format!("{path} matches the registry this process built"),
549 Vec::new(),
550 ),
551 Ok(_) => (
552 HealthStatus::Fail,
553 format!("{path} differs from the registry this process built"),
554 vec![format!("{path} (differs)")],
555 ),
556 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (
560 HealthStatus::Ok,
561 format!("{path} is not committed here, so there is nothing to compare"),
562 Vec::new(),
563 ),
564 Err(e) => (
565 HealthStatus::Unknown,
566 format!("{path} could not be read: {e}"),
567 vec![format!("{path} (unreadable)")],
568 ),
569 };
570 record(
571 &mut checks,
572 &ctx.progress,
573 HealthCheck {
574 id: "generated-registry".into(),
575 title: "Committed projections".into(),
576 status,
577 detail,
578 decided_by:
579 "the same rendering `majordomus generate` writes, compared with what is committed"
580 .into(),
581 evidence: vec!["majordomus generate --check".into()],
582 findings,
583 },
584 );
585
586 let continuity = crate::continuity::Machine::open_read(
593 root,
594 super::mesh::declaration(ctx).and_then(Result::ok),
595 )
596 .and_then(|m| crate::continuity::status(&m));
597 let (status, detail, findings) = continuity_health(continuity);
598 record(
599 &mut checks,
600 &ctx.progress,
601 HealthCheck {
602 id: "continuity".into(),
603 title: "Handovers published across machines".into(),
604 status,
605 detail,
606 decided_by: "the admission and lineage checks of `continuity.status`".into(),
607 evidence: vec!["majordomus-cli continuity status".into()],
608 findings,
609 },
610 );
611
612 let checkout = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
620 let local_half = server::local_half(&checkout);
621 let lease_path = crate::lease::lease_file(&checkout, &local_half);
622 let (standing, reason, lease) = server::standing_at(&checkout, &local_half);
623 let detail = match lease.document() {
624 Some(doc) => format!(
625 "{}: {}, version {}, pid {}, since {}",
626 standing.as_str(),
627 doc.url.as_deref().unwrap_or("no address published yet"),
628 doc.version.as_deref().unwrap_or("unstated"),
629 doc.pid,
630 doc.started_at
631 ),
632 None if standing == ServerStanding::Absent => format!(
633 "absent: no lease at {}, so nothing serves this checkout",
634 lease_path.display()
635 ),
636 None => format!(
637 "{}: the lease at {} holds no document",
638 standing.as_str(),
639 lease_path.display()
640 ),
641 };
642 let orphaned = crate::lease::was_lost();
648 let mut findings: Vec<String> = reason.into_iter().collect();
649 if orphaned {
650 findings.push(
651 "this process no longer holds the lease: it serves the sessions it already had \
652 and takes on no new ones; what is described above is another process"
653 .into(),
654 );
655 }
656 record(
657 &mut checks,
658 &ctx.progress,
659 HealthCheck {
660 id: "server".into(),
661 title: "The shared server".into(),
662 status: if orphaned {
663 HealthStatus::Warn
664 } else {
665 match standing {
666 ServerStanding::Absent | ServerStanding::Starting | ServerStanding::Ready => {
671 HealthStatus::Ok
672 }
673 ServerStanding::Outdated | ServerStanding::Stale => HealthStatus::Warn,
679 }
680 },
681 detail: if orphaned {
682 format!("{detail}; and this process is not it")
683 } else {
684 detail
685 },
686 decided_by:
687 "the decision `server.status` makes, from this checkout's lease and one probe of the server it names"
688 .into(),
689 evidence: vec!["majordomus serve status".into()],
690 findings,
691 },
692 );
693
694 record(
702 &mut checks,
703 &ctx.progress,
704 peers_check(
705 crate::lease::held()
706 .is_some()
707 .then(|| ctx.peers.list())
708 .as_deref(),
709 ),
710 );
711
712 let mesh = if ctx.mesh.decided() {
715 let verdict = crate::mesh::doctor::runtime_verdict(
716 super::mesh::declaration(ctx),
717 Some(&ctx.mesh.status()),
718 );
719 HealthCheck {
720 id: "mesh".into(),
721 title: "The mesh runtime".into(),
722 status: if verdict.ok {
723 HealthStatus::Ok
724 } else {
725 HealthStatus::Fail
726 },
727 detail: verdict.detail,
728 decided_by: "mesh doctor's runtime check, over this server's mesh.status".into(),
729 evidence: Vec::new(),
730 findings: verdict.remediation.into_iter().collect(),
731 }
732 } else {
733 HealthCheck {
734 id: "mesh".into(),
735 title: "The mesh runtime".into(),
736 status: HealthStatus::Unknown,
737 detail: "this process runs no shared server, so no mesh was activated or declined here; ask the server (GET /api/v1/health) or run `majordomus mesh doctor`".into(),
738 decided_by: "whether a shared server decided the mesh in this process".into(),
739 evidence: Vec::new(),
740 findings: Vec::new(),
741 }
742 };
743 record(&mut checks, &ctx.progress, mesh);
744
745 let status = checks
746 .iter()
747 .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status));
748 let mut tallies: BTreeMap<String, usize> = BTreeMap::new();
749 for c in &checks {
750 *tallies.entry(c.status.as_str().into()).or_insert(0) += 1;
751 }
752 let observed = [
753 ("layer", &index.repository.observed.index),
754 ("git", &index.repository.observed.git),
755 ]
756 .into_iter()
757 .filter(|(id, o)| !o.observed_at.is_empty() && checks.iter().any(|c| c.id == *id))
758 .map(|(id, o)| (id.to_string(), o.clone()))
759 .collect();
760 Ok(Health {
761 status,
762 repository: repository_status(&checks),
763 tallies,
764 checks,
765 observed,
766 })
767}
768
769pub fn module() -> ModuleDescriptor {
771 module! {
772 id: "health",
773 title: "Health",
774 description: "Whether what this process serves is healthy, decided by the engines that already decide it: the index's diagnostics, the registry builder, the benchmark projection's coverage, the comparison `generate --check` makes and the lease reading `server.status` answers from. No check here has an opinion of its own.",
775 stability: Stability::BehaviorallyVerified,
776 capabilities: [
777 capability! {
778 id: "health.report",
779 title: "Health of this process",
780 description: "Every dimension of what this process serves — the layer as it was read, the registry, the scope, version control, benchmark coverage, the committed registry manifest, the shared server of this checkout and the attached peers — each decided by the engine that owns it, with the command that reproduces the verdict.",
781 input: Empty,
782 output: Health,
783 stability: Stability::BehaviorallyVerified,
784 exposure: Exposure {
785 mcp: Some(McpExposure {
786 tool: Some("majordomus_health".into()),
787 resource: Some(McpResource { uri: HEALTH_URI.into(), name: "health".into() }),
788 }),
789 http: get("/api/v1/health"),
790 cli: None,
791 },
792 tags: ["health", "introspection"],
793 cache: CachePolicy::Process { max_entries: 4, ttl_seconds: Some(5) },
794 handler: health,
795 },
796 capability! {
797 id: "health.live",
798 title: "Liveness",
799 description: "Is this process alive: the cheapest true statement this executable can make about itself, with the version that answered. No filesystem traversal, no index build, no network — this is what a hosting platform polls, and it must cost nothing to say.",
800 input: Empty,
801 output: Liveness,
802 stability: Stability::BehaviorallyVerified,
803 exposure: Exposure { mcp: None, http: get("/api/v1/live"), cli: None },
804 tags: ["health", "deployment"],
805 handler: liveness,
806 },
807 capability! {
808 id: "health.ready",
809 title: "Readiness",
810 description: "Can this process serve traffic: the registry and the index it built at start-up, already resident, and how the layer read. Only local initialisation — never an external provider, a database or another service, because a readiness check that probes a dependency fails a deployment for something that is not this process.",
811 input: Empty,
812 output: Readiness,
813 stability: Stability::BehaviorallyVerified,
814 exposure: Exposure { mcp: None, http: get("/api/v1/ready"), cli: None },
815 tags: ["health", "deployment"],
816 handler: readiness,
817 },
818 ],
819 }
820}
821
822fn continuity_health(
827 status: Result<crate::continuity::Status, String>,
828) -> (HealthStatus, String, Vec<String>) {
829 match status {
830 Ok(st) => {
831 let worst = st
832 .diagnostics
833 .iter()
834 .map(|d| match d.severity {
835 Severity::Error => HealthStatus::Fail,
836 Severity::Warning => HealthStatus::Warn,
837 _ => HealthStatus::Ok,
838 })
839 .fold(HealthStatus::Ok, HealthStatus::worse);
840 (
841 worst,
842 format!(
843 "{} record(s) admitted, {} refused, {} resumable from another device",
844 st.store.records,
845 st.store.refused,
846 st.resumable.len()
847 ),
848 st.diagnostics
849 .iter()
850 .filter(|d| d.severity != Severity::Info)
851 .map(|d| {
852 format!(
853 "{} {}{}",
854 d.code,
855 d.path
856 .as_deref()
857 .map(|p| format!("{p}: "))
858 .unwrap_or_default(),
859 d.message
860 )
861 })
862 .collect(),
863 )
864 }
865 Err(e) => (
866 HealthStatus::Unknown,
867 format!("the continuity store could not be read: {e}"),
868 Vec::new(),
869 ),
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use crate::synthetic::{Shape, SyntheticRepository};
877
878 #[test]
881 fn attached_clients_are_unknown_without_a_board() {
882 let c = peers_check(None);
883 assert_eq!(c.status, HealthStatus::Unknown, "{c:?}");
884 }
885
886 #[test]
888 fn attached_clients_are_ok_when_every_live_peer_announced() {
889 let board = crate::peers::PeerBoard::new();
890 let a = board.attach(crate::peers::Transport::Http);
891 board.announce(&a, "writing docs", vec!["docs".into()]);
892 let c = peers_check(Some(&board.list()));
893 assert_eq!(c.status, HealthStatus::Ok, "{c:?}");
894 assert!(
895 c.detail.starts_with("1 attached (1 announced) · 0 gone"),
896 "{c:?}"
897 );
898 }
899
900 #[test]
903 fn attached_clients_warn_on_silent_or_gone_peers() {
904 let board = crate::peers::PeerBoard::new();
905 board.attach(crate::peers::Transport::Stdio);
906 let c = peers_check(Some(&board.list()));
907 assert_eq!(c.status, HealthStatus::Warn, "a silent peer: {c:?}");
908
909 let board = crate::peers::PeerBoard::new();
910 let gone = board.attach(crate::peers::Transport::Http);
911 board.announce(&gone, "left", vec!["a".into()]);
912 board.detach(&gone);
913 let c = peers_check(Some(&board.list()));
914 assert_eq!(c.status, HealthStatus::Warn, "a gone peer: {c:?}");
915 assert!(
916 c.detail.starts_with("0 attached (0 announced) · 1 gone"),
917 "{c:?}"
918 );
919 }
920
921 #[test]
924 fn liveness_is_the_same_answer_whatever_the_layer_holds() {
925 let small = SyntheticRepository::new(Shape {
926 rules: 1,
927 ..Shape::default()
928 })
929 .expect("a synthetic repository");
930 let large = SyntheticRepository::new(Shape {
931 rules: 40,
932 ..Shape::default()
933 })
934 .expect("a synthetic repository");
935 let a = liveness(&small.context().expect("a context"), Empty {}).expect("alive");
936 let b = liveness(&large.context().expect("a context"), Empty {}).expect("alive");
937 assert_eq!(a, b);
938 assert!(a.alive);
939 assert_eq!(a.version, crate::VERSION);
940 assert_eq!(a.commit, crate::COMMIT);
941 assert_eq!(a.dirty, crate::DIRTY);
942 }
943
944 #[test]
947 fn readiness_reports_what_this_process_already_holds() {
948 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
949 let ctx = repo.context().expect("a context");
950 let r = readiness(&ctx, Empty {}).expect("ready");
951 assert!(r.ready);
952 assert_eq!(r.capabilities, ctx.registry.summary().total);
953 assert_eq!(r.objects, ctx.index.objects.len());
954 assert_eq!(r.layer, HealthStatus::Ok);
955 assert_eq!(r.commit, crate::COMMIT);
956 assert_eq!(r.dirty, crate::DIRTY);
957 }
958
959 #[test]
963 fn both_are_registered_routes() {
964 let routes: Vec<String> = module()
965 .capabilities
966 .iter()
967 .filter_map(|c| c.capability.exposure.http.as_ref().map(|h| h.path.clone()))
968 .collect();
969 assert!(routes.contains(&"/api/v1/live".to_string()), "{routes:?}");
970 assert!(routes.contains(&"/api/v1/ready".to_string()), "{routes:?}");
971 }
972 #[test]
977 fn the_continuity_check_is_the_worst_of_what_the_store_reports() {
978 use crate::continuity::tests_support::{body, handover, machine, World};
979 use crate::continuity::{self as domain, PublishRequest};
980 let w = World::new();
981 let a_root = w.root("a");
982 let a = machine(&a_root, w.identity("a", "macbook-pro"), None);
983
984 let (status, detail, findings) = continuity_health(domain::status(&a));
985 assert_eq!(status, HealthStatus::Ok);
986 assert!(
987 detail.starts_with("0 record(s) admitted, 0 refused, 0 resumable"),
988 "{detail}"
989 );
990 assert!(findings.is_empty(), "{findings:?}");
991
992 handover(&a_root, "20261003T120000Z", &body("o", "s", "n"), None);
993 domain::publish(&a, &PublishRequest::default()).unwrap();
994 crate::continuity::store::add(&a_root, &[("stray".into(), b"{}".to_vec())], "x\n").unwrap();
995 let st = domain::status(&a).unwrap();
996 let severities: Vec<Severity> = st.diagnostics.iter().map(|d| d.severity).collect();
997 let (status, detail, findings) = continuity_health(Ok(st.clone()));
998 assert_ne!(status, HealthStatus::Ok, "{severities:?}");
999 assert!(
1000 detail.starts_with("1 record(s) admitted, 1 refused"),
1001 "{detail}"
1002 );
1003 assert!(
1004 findings.iter().any(|f| f.contains("records/stray.json: ")),
1005 "{findings:?}"
1006 );
1007
1008 let with = |severity: Severity, path: Option<&str>| {
1010 let mut st = st.clone();
1011 st.diagnostics = vec![crate::model::Diagnostic {
1012 severity,
1013 code: "continuity.sample".into(),
1014 path: path.map(String::from),
1015 message: "said".into(),
1016 }];
1017 continuity_health(Ok(st))
1018 };
1019 let (status, _, findings) = with(Severity::Error, Some("records/x.json"));
1020 assert_eq!(status, HealthStatus::Fail);
1021 assert_eq!(findings, ["continuity.sample records/x.json: said"]);
1022 let (status, _, findings) = with(Severity::Warning, None);
1023 assert_eq!(status, HealthStatus::Warn);
1024 assert_eq!(findings, ["continuity.sample said"]);
1025 let (status, _, findings) = with(Severity::Info, None);
1026 assert_eq!(status, HealthStatus::Ok);
1027 assert!(findings.is_empty());
1028
1029 let (status, detail, findings) = continuity_health(Err("no device".into()));
1030 assert_eq!(status, HealthStatus::Unknown);
1031 assert_eq!(detail, "the continuity store could not be read: no device");
1032 assert!(findings.is_empty());
1033 }
1034
1035 #[test]
1039 fn the_repositorys_verdict_is_decided_where_no_server_runs() {
1040 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1041 let ctx = repo.context().expect("a context");
1042 let report = health(&ctx, Empty {}).expect("a report");
1043 let of = |id: &str| {
1044 report
1045 .checks
1046 .iter()
1047 .find(|c| c.id == id)
1048 .unwrap_or_else(|| panic!("no {id} check"))
1049 .status
1050 };
1051 assert_eq!(of("peers"), HealthStatus::Unknown);
1052 assert_eq!(of("mesh"), HealthStatus::Unknown);
1053 assert_eq!(report.status, HealthStatus::Unknown);
1054 for id in PROCESS_DIMENSIONS {
1055 of(id);
1056 }
1057 let expected = report
1058 .checks
1059 .iter()
1060 .filter(|c| !PROCESS_DIMENSIONS.contains(&c.id.as_str()))
1061 .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status));
1062 assert_eq!(report.repository, expected);
1063 assert_eq!(report.repository, repository_status(&report.checks));
1064 }
1065
1066 #[test]
1070 fn an_uncommitted_registry_manifest_is_ok_and_an_unreadable_one_is_unknown() {
1071 let check = |repo: &SyntheticRepository| {
1072 let ctx = repo.context().expect("a context");
1073 health(&ctx, Empty {})
1074 .expect("a report")
1075 .checks
1076 .into_iter()
1077 .find(|c| c.id == "generated-registry")
1078 .expect("the check is reported")
1079 };
1080 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1081 let manifest = repo.root().join(generate::OUT_DIR).join("registry.json");
1082 assert!(!manifest.exists(), "the fixture commits no manifest");
1083 let absent = check(&repo);
1084 assert_eq!(absent.status, HealthStatus::Ok, "{absent:?}");
1085 assert!(absent.detail.contains("not committed here"), "{absent:?}");
1086 assert!(absent.findings.is_empty(), "{absent:?}");
1087
1088 std::fs::create_dir_all(&manifest).expect("a directory at the manifest's path");
1090 let unreadable = check(&repo);
1091 assert_eq!(unreadable.status, HealthStatus::Unknown, "{unreadable:?}");
1092 assert!(
1093 unreadable.detail.contains("could not be read"),
1094 "{unreadable:?}"
1095 );
1096 assert_eq!(unreadable.findings.len(), 1, "{unreadable:?}");
1097 }
1098
1099 #[test]
1102 fn readiness_names_a_degraded_layer_and_is_still_ready() {
1103 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1104 let ctx = repo.context().expect("a context");
1105 let mut index = repo.index().expect("an index");
1106 index.diagnostics.push(crate::model::Diagnostic {
1107 severity: Severity::Error,
1108 code: "unknown_key".into(),
1109 path: Some("docs/x.md".into()),
1110 message: "key(s) not in schema".into(),
1111 });
1112 index.state = State::Degraded;
1113 let mut degraded = (*ctx).clone();
1114 degraded.index = std::sync::Arc::new(index);
1115 let r = readiness(°raded, Empty {}).expect("ready");
1116 assert!(r.ready, "a degraded layer is served: {r:?}");
1117 assert_eq!(r.layer, HealthStatus::Warn);
1118 let expected = format!(
1119 "layer_degraded: the layer was read with {} diagnostic(s); it is served as read",
1120 degraded.index.diagnostics.len()
1121 );
1122 assert!(r.reasons.contains(&expected), "{:?}", r.reasons);
1123 }
1124
1125 const STALE_CHILD: &str = "MAJORDOMUS_HEALTH_TEST_STALE_CHILD";
1127
1128 #[test]
1132 fn readiness_refuses_a_process_whose_executable_was_removed_under_it() {
1133 if std::env::var_os(STALE_CHILD).is_some() {
1134 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
1135 let ctx = repo.context().expect("a context");
1136 assert_eq!(
1137 crate::lease::serving_replaced_code(),
1138 None,
1139 "pinned while the file is there"
1140 );
1141 std::fs::remove_file(std::env::current_exe().expect("this executable"))
1142 .expect("the copy is removed");
1143 let r = readiness(&ctx, Empty {}).expect("an answer");
1144 assert!(!r.ready, "{r:?}");
1145 assert!(r.stale.is_some(), "{r:?}");
1146 assert!(
1147 r.reasons
1148 .iter()
1149 .any(|x| x.starts_with("stale: ") && x.contains("has been removed")),
1150 "{:?}",
1151 r.reasons
1152 );
1153 return;
1154 }
1155 let dir = tempfile::tempdir().expect("a temp dir");
1156 let copy = dir.path().join("health-tests");
1157 std::fs::copy(std::env::current_exe().expect("this test binary"), ©)
1158 .expect("a copy of this test binary");
1159 let mut attempts = 0;
1163 let out = loop {
1164 let run = std::process::Command::new(©)
1165 .args([
1166 "--exact",
1167 "capability::builtin::health::tests::readiness_refuses_a_process_whose_executable_was_removed_under_it",
1168 "--nocapture",
1169 "--test-threads",
1170 "1",
1171 ])
1172 .env(STALE_CHILD, "1")
1173 .output();
1174 match run {
1175 Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 50 => {
1176 attempts += 1;
1177 std::thread::sleep(std::time::Duration::from_millis(100));
1178 }
1179 other => break other.expect("the copy runs"),
1180 }
1181 };
1182 let stdout = String::from_utf8_lossy(&out.stdout);
1183 assert!(
1184 out.status.success() && stdout.contains("1 passed"),
1185 "{:?}\n{stdout}\n{}",
1186 out.status,
1187 String::from_utf8_lossy(&out.stderr)
1188 );
1189 assert!(!copy.exists(), "the child removed its own executable");
1190 }
1191}