majordomus_cli/environment/services.rs
1//! The local services of a repository: what this executable serves, where, and whether
2//! anything is answering there now.
3//!
4//! Paths are not written here. Each descriptor names the constant that already decides its
5//! route — [`crate::ui::PREFIX`], [`crate::cockpit::PREFIX`],
6//! [`crate::capability::HttpExposure::PREFIX`], [`crate::http::swagger::SWAGGER_PATH`],
7//! [`crate::web::discover::DOCS_MOUNT`] and the
8//! rest of what [`crate::http::openapi::infrastructure_routes`] lists — so a route that
9//! moves moves here too, and `every_infrastructure_route_is_described` fails the build if
10//! a new one is added without a name for it. The base address is the shared server's lease, which is
11//! where a running server publishes it and the only place it is true.
12//!
13//! Availability is decided by a connection attempt with a hard budget and no name
14//! resolution ([`super::probe`]); a service that did not answer in time is `unknown`,
15//! never "down".
16
17use std::path::Path;
18use std::time::Duration;
19
20use crate::capability::HttpExposure;
21use crate::cockpit;
22use crate::http::{mcp, swagger};
23use crate::web::discover::DOCS_MOUNT;
24
25use super::{ServiceAvailability, ServiceState};
26
27/// How long the connection attempt to a published address may take. Loopback either
28/// answers in microseconds or is not there; anything slower is a machine under load, and
29/// the snapshot says `unknown` rather than making a shell prompt wait for it.
30pub const PROBE_BUDGET: Duration = Duration::from_millis(80);
31
32/// One service this executable serves, and the constant that decides where.
33struct Descriptor {
34 id: &'static str,
35 title: &'static str,
36 path: &'static str,
37}
38
39/// Every service, in the order a snapshot reports them: the human surfaces first, the
40/// machine ones after.
41fn descriptors() -> [Descriptor; 9] {
42 [
43 Descriptor {
44 // The product's own surface (ADR 0135), first because it is the one a person
45 // is meant to open; the Cockpit after it is the technical layer.
46 id: crate::ui::SURFACE,
47 title: "majordomus ui",
48 path: crate::ui::PREFIX,
49 },
50 Descriptor {
51 id: "cockpit",
52 title: "Cockpit",
53 path: cockpit::PREFIX,
54 },
55 Descriptor {
56 id: "docs",
57 title: "Documentation",
58 path: DOCS_MOUNT,
59 },
60 Descriptor {
61 id: "swagger",
62 title: "Swagger UI",
63 path: swagger::SWAGGER_PATH,
64 },
65 Descriptor {
66 id: "api",
67 title: "HTTP API",
68 // The prefix every capability route lives under; the routes themselves are the
69 // registry's and are not repeated anywhere. Without the trailing slash, because
70 // the surface is mounted without it (`web::discover::native_all` trims it) and
71 // this list is compared against what the server actually serves.
72 path: HttpExposure::PREFIX.trim_end_matches('/'),
73 },
74 Descriptor {
75 id: "events",
76 title: "Execution events",
77 // The stream a Cockpit or a terminal follows an execution over. It arrived with
78 // the execution control plane, after this list was written, which is exactly the
79 // gap `every_infrastructure_route_is_described` exists to close.
80 path: crate::http::events::PATH,
81 },
82 Descriptor {
83 id: "openapi",
84 title: "OpenAPI document",
85 path: swagger::SPEC_PATH,
86 },
87 Descriptor {
88 id: "mcp",
89 title: "MCP over HTTP",
90 path: mcp::PATH,
91 },
92 Descriptor {
93 id: "index",
94 title: "Home page",
95 path: "/",
96 },
97 ]
98}
99
100/// The services of the repository at `root`, with the address a running server published.
101///
102/// `probe` decides whether the address is contacted at all: a resolution that may not
103/// spend the time reports every service `unknown`, which is what it knows.
104pub fn resolve(root: &Path, local_half: &str, probe: bool) -> Vec<ServiceState> {
105 let base = published_url(root, local_half);
106 // One connection attempt for the whole server, not one per route: they are all the
107 // same socket, and seven probes would cost seven times as much to learn one thing.
108 let availability = match (&base, probe) {
109 (Some(url), true) => super::probe::reachable(url, PROBE_BUDGET),
110 (Some(_), false) => ServiceAvailability::Unknown,
111 (None, _) => ServiceAvailability::NotRunning,
112 };
113 descriptors()
114 .into_iter()
115 .map(|d| ServiceState {
116 id: d.id.into(),
117 title: d.title.into(),
118 path: d.path.into(),
119 url: base.as_ref().map(|base| join(base, d.path)),
120 availability,
121 })
122 .collect()
123}
124
125/// The address the repository's shared server published, from its lease. Reads one small
126/// file and contacts nothing; [`crate::lease::probe`] is the version that checks whether
127/// the server is really there, and it costs an HTTP round trip.
128pub fn published_url(root: &Path, local_half: &str) -> Option<String> {
129 crate::lease::LeaseFile::read(&crate::lease::lease_file(root, local_half))
130 .document()?
131 .url
132 .clone()
133}
134
135/// A base address and a route, joined without a doubled or missing slash.
136///
137/// ```
138/// use majordomus_cli::environment::services::join;
139/// assert_eq!(join("http://127.0.0.1:8741", "/cockpit"), "http://127.0.0.1:8741/cockpit");
140/// assert_eq!(join("http://127.0.0.1:8741/", "/"), "http://127.0.0.1:8741/");
141/// assert_eq!(join("http://127.0.0.1:8741", "/api/v1/"), "http://127.0.0.1:8741/api/v1/");
142/// ```
143pub fn join(base: &str, path: &str) -> String {
144 format!("{}{}", base.trim_end_matches('/'), path)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::http::openapi::infrastructure_routes;
151
152 /// The reason the paths are constants rather than strings: a route added to the
153 /// server's own infrastructure list without a name here would be a service the
154 /// Cockpit, the banner and the documentation never mention. This is what notices.
155 #[test]
156 fn every_infrastructure_route_is_described() {
157 let described: Vec<&str> = descriptors().iter().map(|d| d.path).collect();
158 for route in infrastructure_routes() {
159 assert!(
160 described.contains(&route.path.as_str()),
161 "the server serves {} and no service describes it",
162 route.path
163 );
164 }
165 }
166
167 #[test]
168 fn every_service_has_a_distinct_id_and_a_path_the_router_could_serve() {
169 let mut ids: Vec<&str> = descriptors().iter().map(|d| d.id).collect();
170 let count = ids.len();
171 ids.sort();
172 ids.dedup();
173 assert_eq!(ids.len(), count, "two services share an id");
174 for d in descriptors() {
175 assert!(d.path.starts_with('/'), "{} has a relative path", d.id);
176 assert!(!d.title.is_empty());
177 }
178 }
179
180 #[test]
181 fn without_a_lease_nothing_is_running_and_no_url_is_invented() {
182 let dir = tempfile::tempdir().expect("a temporary directory");
183 let services = resolve(dir.path(), ".ai/local", true);
184 assert!(!services.is_empty());
185 for s in &services {
186 assert_eq!(s.availability, ServiceAvailability::NotRunning);
187 assert_eq!(s.url, None, "{} invented an address", s.id);
188 }
189 }
190
191 #[test]
192 fn a_lease_of_another_schema_is_not_read() {
193 let dir = tempfile::tempdir().expect("a temporary directory");
194 let path = dir.path().join(".ai/local").join(crate::lease::LEASE_PATH);
195 std::fs::create_dir_all(path.parent().expect("a parent")).expect("a directory");
196 std::fs::write(
197 &path,
198 r#"{"schema":"something/else","url":"http://127.0.0.1:1"}"#,
199 )
200 .expect("a lease");
201 assert_eq!(published_url(dir.path(), ".ai/local"), None);
202 }
203
204 #[test]
205 fn a_published_address_becomes_every_services_url() {
206 let dir = tempfile::tempdir().expect("a temporary directory");
207 let path = dir.path().join(".ai/local").join(crate::lease::LEASE_PATH);
208 std::fs::create_dir_all(path.parent().expect("a parent")).expect("a directory");
209 std::fs::write(
210 &path,
211 format!(
212 r#"{{"schema":"{}","url":"http://127.0.0.1:8741"}}"#,
213 crate::lease::SCHEMA
214 ),
215 )
216 .expect("a lease");
217 // No probe: this asserts the addresses, not what answers at them.
218 let services = resolve(dir.path(), ".ai/local", false);
219 let cockpit = services
220 .iter()
221 .find(|s| s.id == "cockpit")
222 .expect("the cockpit");
223 assert_eq!(
224 cockpit.url.as_deref(),
225 Some("http://127.0.0.1:8741/cockpit")
226 );
227 assert_eq!(
228 cockpit.availability,
229 ServiceAvailability::Unknown,
230 "a resolution that did not probe knows nothing, and says so"
231 );
232 }
233}