Skip to main content

rustlavel_discovery/
server.rs

1//! The registry, as something you can run.
2//!
3//! A plugin rather than a binary, so a registry is an ordinary Rustlavel
4//! application with one line in it — and so the same process can serve the
5//! registry, a health endpoint and whatever else an operator wants at the same
6//! address, which is what they will want at three in the morning.
7//!
8//! The routes are Eureka's, exactly, so a Spring Cloud client registers here
9//! with its stock configuration pointed at this host and nothing else changed.
10
11use std::sync::Arc;
12
13use rustlavel_core::Json;
14use rustlavel_http::{BoxFuture, Plugin, Request, Response, Setup, Status};
15
16use crate::eureka;
17use crate::instance::Status as InstanceStatus;
18use crate::registry::{Mode, Registry};
19use crate::replicate::{Change, Peers, REPLICATION_HEADER, is_replication};
20
21/// A service registry.
22///
23/// ```ignore
24/// App::new()
25///     .plugin(RegistryServer::new().peers(vec!["http://registry-b:8761".into()]))
26///     .run()
27///     .await
28/// ```
29pub struct RegistryServer {
30    registry: Registry,
31    peers: Peers,
32    /// Where the dashboard is mounted, or `None` for no dashboard.
33    dashboard: Option<String>,
34}
35
36impl Default for RegistryServer {
37    fn default() -> Self {
38        RegistryServer::new()
39    }
40}
41
42impl RegistryServer {
43    pub fn new() -> RegistryServer {
44        RegistryServer {
45            registry: Registry::new(),
46            peers: Peers::new(Vec::new()),
47            dashboard: Some("/discovery".to_string()),
48        }
49    }
50
51    /// Share the registry with the rest of the application — a dashboard, or a
52    /// gateway running in the same process.
53    pub fn using(mut self, registry: Registry) -> RegistryServer {
54        self.registry = registry;
55        self
56    }
57
58    pub fn registry(&self) -> &Registry {
59        &self.registry
60    }
61
62    /// The other registry nodes. Pass every node including this one and use
63    /// [`Peers::excluding`] — or pass only the others; both work.
64    pub fn peers(mut self, peers: Peers) -> RegistryServer {
65        self.peers = peers;
66        self
67    }
68
69    /// Move the dashboard, or take it away.
70    ///
71    /// **A registry's dashboard is an internal tool and this plugin puts no
72    /// authentication in front of it.** It lists every host and port you run,
73    /// which is a map of the estate for anybody who reaches it. Mount it behind
74    /// whatever the application already uses, or pass `None` and read the
75    /// registry through [`Registry`] from a page of your own — which is what
76    /// the auth-kit dashboard does.
77    pub fn dashboard(mut self, at: Option<&str>) -> RegistryServer {
78        self.dashboard = at.map(str::to_string);
79        self
80    }
81}
82
83/// Whether this write should be forwarded to the peers.
84fn arrived_from_a_client(request: &Request) -> bool {
85    !is_replication(request.query("isReplication"), request.header(REPLICATION_HEADER))
86}
87
88impl Plugin for RegistryServer {
89    fn name(&self) -> &'static str {
90        "discovery"
91    }
92
93    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
94        let this = Arc::new(*self);
95
96        // The registry is application state as well, so a handler of the
97        // application's own — a dashboard, a gateway — reads the same instances
98        // rather than asking this server over HTTP to tell it about itself.
99        setup.state(this.registry.clone());
100
101        let owner = Arc::clone(&this);
102        setup.router.post("/eureka/apps/{app}", move |mut request: Request| {
103            let owner = Arc::clone(&owner);
104            Box::pin(async move {
105                let Some(body) = request.json().cloned() else {
106                    return Response::new(Status::BAD_REQUEST)
107                        .with_json(problem("a registration must be a JSON body"));
108                };
109                let Some(instance) = eureka::read_instance(&body) else {
110                    return Response::new(Status::BAD_REQUEST)
111                        .with_json(problem("a registration needs an app and a host"));
112                };
113
114                owner.registry.register(instance.clone());
115                if arrived_from_a_client(&request) {
116                    owner.peers.spread(Change::Registered(instance));
117                }
118
119                // 204, which is what Eureka answers and what its clients check
120                // for. A 200 with a body is read as a failure by some of them.
121                Response::no_content()
122            }) as BoxFuture<Response>
123        });
124
125        let owner = Arc::clone(&this);
126        setup.router.put("/eureka/apps/{app}/{id}", move |request: Request| {
127            let owner = Arc::clone(&owner);
128            Box::pin(async move {
129                let (app, id) = named(&request);
130
131                if !owner.registry.renew(&app, &id) {
132                    // 404 is the contract: it is how a client learns it must
133                    // register again, and it is what every client gets after
134                    // this process restarts.
135                    return Response::not_found();
136                }
137
138                if arrived_from_a_client(&request) {
139                    owner.peers.spread(Change::Renewed { service: app, id });
140                }
141                Response::ok()
142            }) as BoxFuture<Response>
143        });
144
145        let owner = Arc::clone(&this);
146        setup.router.put("/eureka/apps/{app}/{id}/status", move |request: Request| {
147            let owner = Arc::clone(&owner);
148            Box::pin(async move {
149                let (app, id) = named(&request);
150                // No `value` at all is `Down`, through `Status::parse`. A status
151                // this server cannot read must not be treated as healthy.
152                let status = InstanceStatus::parse(request.query("value").unwrap_or(""));
153
154                if !owner.registry.set_status(&app, &id, status) {
155                    return Response::not_found();
156                }
157
158                if arrived_from_a_client(&request) {
159                    owner.peers.spread(Change::StatusChanged { service: app, id, status });
160                }
161                Response::ok()
162            }) as BoxFuture<Response>
163        });
164
165        let owner = Arc::clone(&this);
166        setup.router.delete("/eureka/apps/{app}/{id}", move |request: Request| {
167            let owner = Arc::clone(&owner);
168            Box::pin(async move {
169                let (app, id) = named(&request);
170                let removed = owner.registry.cancel(&app, &id);
171
172                // Forwarded even when this node had nothing to remove. The
173                // instance may have registered with a peer and never with this
174                // one, and a cancellation that stopped here would leave it
175                // being handed out by every other node.
176                if arrived_from_a_client(&request) {
177                    owner.peers.spread(Change::Cancelled { service: app, id });
178                }
179
180                match removed {
181                    true => Response::ok(),
182                    false => Response::not_found(),
183                }
184            }) as BoxFuture<Response>
185        });
186
187        let owner = Arc::clone(&this);
188        setup.router.get("/eureka/apps", move |_request: Request| {
189            let owner = Arc::clone(&owner);
190            Box::pin(async move {
191                // Everything, stale included. This is the document a peer reads
192                // to catch up, and filtering it would mean a node that restarts
193                // never learns about an instance its peers are unsure of.
194                Response::json(eureka::applications_json(&owner.registry.all()))
195            }) as BoxFuture<Response>
196        });
197
198        let owner = Arc::clone(&this);
199        setup.router.get("/eureka/apps/{app}", move |request: Request| {
200            let owner = Arc::clone(&owner);
201            Box::pin(async move {
202                let app = request.param("app").unwrap_or("").to_ascii_uppercase();
203                let (instances, mode) = owner.registry.alive(&app);
204
205                if instances.is_empty() {
206                    return Response::not_found();
207                }
208
209                let mut response = Response::json(eureka::application_json(&app, &instances));
210                if mode == Mode::SelfPreservation {
211                    // Said out loud, on every answer, because a caller
212                    // wondering why it reached a dead instance deserves to find
213                    // the reason without reading the registry's logs.
214                    response = response.with_header("x-discovery-self-preservation", "true");
215                }
216                response
217            }) as BoxFuture<Response>
218        });
219
220        // The whole registry as plain JSON, for a dashboard. Eureka's own
221        // document is shaped for Eureka clients; this one is shaped for a page.
222        let owner = Arc::clone(&this);
223        setup.router.get("/discovery/registry.json", move |_request: Request| {
224            let owner = Arc::clone(&owner);
225            Box::pin(async move { Response::json(crate::ui::registry_json(&owner.registry)) })
226                as BoxFuture<Response>
227        });
228
229        if let Some(at) = this.dashboard.clone() {
230            let owner = Arc::clone(&this);
231            setup.router.get(&at, move |_request: Request| {
232                let owner = Arc::clone(&owner);
233                Box::pin(async move { Response::html(crate::ui::page(&owner.registry)) })
234                    as BoxFuture<Response>
235            });
236        }
237    }
238}
239
240fn named(request: &Request) -> (String, String) {
241    (
242        request.param("app").unwrap_or("").to_ascii_uppercase(),
243        request.param("id").unwrap_or("").to_string(),
244    )
245}
246
247fn problem(message: &str) -> Json {
248    Json::object([("message", Json::from(message))])
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::instance::Instance;
255
256    #[test]
257    fn the_dashboard_can_be_taken_away() {
258        assert_eq!(RegistryServer::new().dashboard.as_deref(), Some("/discovery"));
259        assert!(RegistryServer::new().dashboard(None).dashboard.is_none());
260        assert_eq!(
261            RegistryServer::new().dashboard(Some("/ops/discovery")).dashboard.as_deref(),
262            Some("/ops/discovery")
263        );
264    }
265
266    /// A shared registry is how a dashboard in the same process reads the
267    /// instances without asking this server over HTTP about itself.
268    #[test]
269    fn a_registry_can_be_shared_with_the_rest_of_the_application() {
270        let registry = Registry::new();
271        let server = RegistryServer::new().using(registry.clone());
272
273        registry.register(Instance::new("orders", "10.0.0.1", 8080));
274        assert_eq!(server.registry().all().len(), 1);
275    }
276}