Skip to main content

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}