1use std::collections::HashMap;
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::{Arc, OnceLock};
53use std::time::Duration;
54
55use async_trait::async_trait;
56
57use crate::envelope::EventEnvelope;
58use crate::function::{AppError, ComposableFunction};
59use crate::platform::Platform;
60use crate::post_office::PostOffice;
61use crate::trace;
62use crate::util::app_config_reader::AppConfigReader;
63use crate::util::elapsed_time;
64use crate::util::managed_cache::ManagedCache;
65
66pub const INFO_ACTUATOR: &str = "info.actuator.service";
67pub const ROUTES_ACTUATOR: &str = "routes.actuator.service";
68pub const ENV_ACTUATOR: &str = "env.actuator.service";
69pub const HEALTH_ACTUATOR: &str = "health.actuator.service";
70pub const LIVENESS_ACTUATOR: &str = "liveness.actuator.service";
71
72pub fn actuator_instances(config: &AppConfigReader) -> usize {
81 config
82 .get_property("worker.instances.actuator.services")
83 .and_then(|value| value.trim().parse::<usize>().ok())
84 .unwrap_or(5)
85}
86
87const SHOW_ENV: &str = "show.env.variables";
88const SHOW_PROPERTIES: &str = "show.application.properties";
89const REQUIRED_SERVICES: &str = "mandatory.health.dependencies";
90const OPTIONAL_SERVICES: &str = "optional.health.dependencies";
91
92#[derive(Clone, Copy)]
96pub enum ActuatorKind {
97 Info,
98 Routes,
99 Env,
100 Health,
101 Liveness,
102}
103
104pub struct ActuatorContext {
108 platform: Platform,
109 health_status: AtomicBool,
110 start_time: std::time::SystemTime,
111 required: Vec<String>,
112 optional: Vec<String>,
113 description: String,
114 app_version: String,
115}
116
117impl ActuatorContext {
118 pub fn new(platform: &Platform) -> Arc<Self> {
119 let config = AppConfigReader::get_instance();
120 let split = |key: &str| -> Vec<String> {
121 config
122 .get_property_or(key, "")
123 .split([',', ' '])
124 .map(str::trim)
125 .filter(|s| !s.is_empty())
126 .map(str::to_string)
127 .collect()
128 };
129 let required = split(REQUIRED_SERVICES);
130 let optional = split(OPTIONAL_SERVICES);
131 if !required.is_empty() {
132 log::info!("Mandatory service dependencies - {required:?}");
133 }
134 if !optional.is_empty() {
135 log::info!("Optional services dependencies - {optional:?}");
136 }
137 Arc::new(ActuatorContext {
138 platform: platform.clone(),
139 health_status: AtomicBool::new(true),
140 start_time: std::time::SystemTime::now(),
141 required,
142 optional,
143 description: config.get_property_or("info.app.description", &Platform::name()),
144 app_version: config.get_property_or("info.app.version", env!("CARGO_PKG_VERSION")),
148 })
149 }
150
151 fn app_block(&self) -> serde_json::Value {
152 serde_json::json!({
153 "name": Platform::name(),
154 "version": self.app_version,
155 "description": self.description,
156 })
157 }
158}
159
160pub struct ActuatorServices {
162 kind: ActuatorKind,
163 context: Arc<ActuatorContext>,
164}
165
166impl ActuatorServices {
167 pub fn new(kind: ActuatorKind, context: Arc<ActuatorContext>) -> Self {
168 ActuatorServices { kind, context }
169 }
170}
171
172#[async_trait]
173impl ComposableFunction for ActuatorServices {
174 async fn handle_event(
175 &self,
176 _headers: HashMap<String, String>,
177 _input: EventEnvelope,
178 _instance: usize,
179 ) -> Result<EventEnvelope, AppError> {
180 let context = &self.context;
181 match self.kind {
182 ActuatorKind::Liveness => {
183 if context.health_status.load(Ordering::SeqCst) {
185 Ok(EventEnvelope::new()
186 .set_header("content-type", "text/plain")
187 .set_body("OK")?)
188 } else {
189 Ok(EventEnvelope::new()
190 .set_status(400)
191 .set_header("content-type", "text/plain")
192 .set_body("Unhealthy. Please check '/health' endpoint.")?)
193 }
194 }
195 ActuatorKind::Routes => {
196 EventEnvelope::new()
205 .set_header("content-type", "application/json")
206 .set_body(serde_json::json!({
207 "app": context.app_block(),
208 "routing": local_routing(&context.platform),
209 }))
210 }
211 ActuatorKind::Info => {
212 let now = std::time::SystemTime::now();
213 let uptime = now.duration_since(context.start_time).unwrap_or_default();
214 EventEnvelope::new()
216 .set_header("content-type", "application/json")
217 .set_body(serde_json::json!({
218 "app": context.app_block(),
219 "runtime": {
220 "language": "rust",
221 "platform_core": env!("CARGO_PKG_VERSION"),
222 },
223 "origin": Platform::origin(),
224 "time": {
225 "start": trace::iso8601_utc(context.start_time),
226 "current": trace::iso8601_utc(now),
227 },
228 "up_time": elapsed_time(uptime),
229 }))
230 }
231 ActuatorKind::Env => {
232 let config = AppConfigReader::get_instance();
233 let list = |key: &str| -> Vec<String> {
234 config
235 .get_property_or(key, "")
236 .split([',', ' '])
237 .map(str::trim)
238 .filter(|s| !s.is_empty())
239 .map(str::to_string)
240 .collect()
241 };
242 let mut environment = serde_json::Map::new();
243 for name in list(SHOW_ENV) {
244 let value = std::env::var(&name).unwrap_or_default();
245 environment.insert(name, serde_json::Value::String(value));
246 }
247 let mut properties = serde_json::Map::new();
248 for name in list(SHOW_PROPERTIES) {
249 let value = config.get_property(&name).unwrap_or_default();
250 properties.insert(name, serde_json::Value::String(value));
251 }
252 EventEnvelope::new()
253 .set_header("content-type", "application/json")
254 .set_body(serde_json::json!({
255 "app": context.app_block(),
256 "env": {
257 "environment": environment,
258 "properties": properties,
259 },
260 }))
261 }
262 ActuatorKind::Health => {
263 let po = PostOffice::new(&context.platform);
264 let mut dependency: Vec<serde_json::Value> = Vec::new();
265 check_services(&po, &context.optional, false, &mut dependency).await;
267 let up = check_services(&po, &context.required, true, &mut dependency).await;
268 context.health_status.store(up, Ordering::SeqCst);
269 let mut result = serde_json::Map::new();
270 if dependency.is_empty() {
271 result.insert(
272 "message".into(),
273 serde_json::Value::String(
274 "Did you forget to define mandatory.health.dependencies or optional.health.dependencies"
275 .to_string(),
276 ),
277 );
278 }
279 result.insert("dependency".into(), serde_json::Value::Array(dependency));
280 result.insert(
281 "status".into(),
282 serde_json::Value::String(if up { "UP" } else { "DOWN" }.to_string()),
283 );
284 result.insert(
285 "origin".into(),
286 serde_json::Value::String(Platform::origin().to_string()),
287 );
288 result.insert("name".into(), serde_json::Value::String(Platform::name()));
289 Ok(EventEnvelope::new()
290 .set_status(if up { 200 } else { 400 }) .set_header("content-type", "application/json")
292 .set_body(serde_json::Value::Object(result))?)
293 }
294 }
295 }
296}
297
298fn health_info_cache() -> &'static Arc<ManagedCache> {
306 static CACHE: OnceLock<Arc<ManagedCache>> = OnceLock::new();
307 CACHE.get_or_init(|| ManagedCache::create_cache("health.info", 5000))
308}
309
310async fn check_services(
315 po: &PostOffice,
316 services: &[String],
317 required: bool,
318 dependency: &mut Vec<serde_json::Value>,
319) -> bool {
320 let mut all_up = true;
321 for route in services {
322 let mut entry = serde_json::Map::new();
323 entry.insert("route".into(), serde_json::Value::String(route.clone()));
324 entry.insert("required".into(), serde_json::Value::Bool(required));
325 let cache = health_info_cache();
329 let info_key = format!("info/{route}");
330 if !cache.exists(&info_key) {
331 let info_request = EventEnvelope::new()
332 .set_to(route)
333 .set_header("type", "info");
334 if let Ok(info) = po.request(info_request, Duration::from_secs(3)).await {
335 if let Ok(body @ serde_json::Value::Object(_)) = info.body_as::<serde_json::Value>()
336 {
337 cache.put(&info_key, body);
338 }
339 }
340 }
341 if let Some(info) = cache.get_as::<serde_json::Value>(&info_key) {
342 if let serde_json::Value::Object(map) = info.as_ref() {
343 for (key, value) in map {
344 entry.insert(key.clone(), value.clone());
345 }
346 }
347 }
348 let health_request = EventEnvelope::new()
350 .set_to(route)
351 .set_header("type", "health");
352 match po.request(health_request, Duration::from_secs(10)).await {
353 Ok(response) => {
354 entry.insert(
355 "status_code".into(),
356 serde_json::Value::from(response.status()),
357 );
358 if let Ok(message) = response.body_as::<serde_json::Value>() {
359 if message.is_string() || message.is_object() {
360 entry.insert("message".into(), message);
361 }
362 }
363 if response.has_error() {
364 all_up = false;
365 }
366 }
367 Err(e) => {
368 all_up = false;
369 entry.insert("status_code".into(), serde_json::Value::from(e.status()));
370 entry.insert(
371 "message".into(),
372 serde_json::Value::String(format!("Please check - {}", e.message())),
373 );
374 }
375 }
376 dependency.push(serde_json::Value::Object(entry));
377 }
378 all_up
379}
380
381fn local_routing(platform: &crate::platform::Platform) -> serde_json::Value {
389 use std::sync::OnceLock;
390 static CACHE: OnceLock<std::sync::Arc<crate::util::managed_cache::ManagedCache>> =
391 OnceLock::new();
392 let cache = CACHE.get_or_init(|| {
393 crate::util::managed_cache::ManagedCache::create_cache("local.routing.info", 10 * 60 * 1000)
394 });
395 if let Some(cached) = cache.get("local.routing") {
396 if let Some(value) = cached.downcast_ref::<serde_json::Value>() {
397 return value.clone();
398 }
399 }
400 let mut public = std::collections::BTreeMap::new();
401 let mut private = std::collections::BTreeMap::new();
402 for route in platform.routes() {
403 let instances = platform.instances(&route).unwrap_or(1);
404 if platform.is_private(&route).unwrap_or(true) {
405 private.insert(route, instances);
406 } else {
407 public.insert(route, instances);
408 }
409 }
410 let value = serde_json::json!({
411 "public": compress_route_families(public),
412 "private": compress_route_families(private),
413 });
414 cache.put("local.routing", value.clone());
415 value
416}
417
418fn compress_route_families(
426 routes: std::collections::BTreeMap<String, usize>,
427) -> std::collections::BTreeMap<String, usize> {
428 let mut result = std::collections::BTreeMap::new();
429 let mut families: std::collections::BTreeMap<String, std::collections::BTreeMap<u64, usize>> =
430 std::collections::BTreeMap::new();
431 for (route, instances) in routes {
432 let family = route.rfind('.').and_then(|dot| {
433 let suffix = &route[dot + 1..];
434 if !suffix.is_empty() && suffix.len() < 10 && suffix.bytes().all(|b| b.is_ascii_digit())
435 {
436 let n: u64 = suffix.parse().ok()?;
437 (suffix == n.to_string()).then(|| (route[..dot + 1].to_string(), n))
439 } else {
440 None
441 }
442 });
443 match family {
444 Some((base, n)) => {
445 families.entry(base).or_default().insert(n, instances);
446 }
447 None => {
448 result.insert(route, instances);
449 }
450 }
451 }
452 for (base, members) in families {
453 let min = *members.keys().next().expect("non-empty family");
454 let max = *members.keys().last().expect("non-empty family");
455 let uniform = members
456 .values()
457 .collect::<std::collections::HashSet<_>>()
458 .len()
459 == 1;
460 if members.len() > 1 && uniform && members.len() as u64 == max - min + 1 {
461 let instances = *members.values().next().expect("non-empty family");
462 result.insert(format!("{base}{min} - {max}"), instances);
463 } else {
464 for (n, instances) in members {
465 result.insert(format!("{base}{n}"), instances);
466 }
467 }
468 }
469 result
470}