Skip to main content

platform_core/
actuator.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Actuator endpoints — Rust port of the Java `ActuatorServices`
18//! (`org.platformlambda.core.services.ActuatorServices`), registered by the
19//! lifecycle's essential-services phase and exposed over REST automation via
20//! the default endpoints (`/info`, `/info/routes`, `/env`, `/health`,
21//! `/livenessprobe`).
22//!
23//! - **`/info`** — application identity (name, version, description), runtime,
24//!   origin, start/current time, uptime.
25//! - **`/info/routes`** — the app block plus the local routing table split by
26//!   visibility (`routing.public` / `routing.private`, route → instance
27//!   count; Java `handleInfoRoute`). Java's optional blocks (`journal`,
28//!   `route_substitution`) and the mesh `network` table are omitted when
29//!   empty — subsystems this port does not have, so the response is
30//!   `{app, routing}` here.
31//! - **`/env`** — selected environment variables (`show.env.variables`) and
32//!   selected base-configuration parameters (`show.application.properties`) —
33//!   opt-in lists, so secrets are never dumped wholesale (Java parity).
34//! - **`/health`** — runs the health-check functions listed in
35//!   `mandatory.health.dependencies` / `optional.health.dependencies`
36//!   (comma-separated routes): each is called with header `type=info` then
37//!   `type=health`; a non-200 health status marks the dependency down. All
38//!   mandatory up → `UP` (HTTP 200); any mandatory down → `DOWN` (HTTP 400,
39//!   Java parity). The outcome feeds the liveness state.
40//! - **`/livenessprobe`** — `OK` (text) while the last health outcome is good,
41//!   else HTTP 400 `Unhealthy. Please check '/health' endpoint.`
42//!
43//! Deferred (maintainer-approved): `/info/lib` — Java lists JAR dependencies
44//! from the archive manifest; a Rust binary has no runtime dependency
45//! manifest (a build-script–embedded cargo metadata could provide it later).
46//! Also deferred: XML responses. The Java per-route info cache is ported
47//! (increment 71): the `type=info` lookup is cached 5 s per dependency via
48//! `ManagedCache("health.info")` — see `check_services`.
49
50use 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
72/// The worker-instance count for the actuator family (default 5 — a rule of
73/// thumb, like every initial instance count): operations teams fine-tune it
74/// via `worker.instances.actuator.services` in QA/Perf environments before
75/// promoting to production. ONE family key covers all five actuator routes —
76/// and it is the SAME key the Java engine carries: its actuators are one
77/// aliased class whose primary route is `actuator.services` (that route
78/// itself is unported here), so a single runbook line tunes both engines.
79/// Numeric value wins, anything else falls back (env_instances semantics).
80pub 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/// Which actuator a registered instance serves (Java switches on the invoked
93/// route via the `my_route` header; the Rust port parameterizes at
94/// registration instead).
95#[derive(Clone, Copy)]
96pub enum ActuatorKind {
97    Info,
98    Routes,
99    Env,
100    Health,
101    Liveness,
102}
103
104/// State shared by all four actuator registrations: the liveness flag follows
105/// the most recent health outcome (Java `healthStatus`), and the app identity
106/// is resolved once.
107pub 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            // an application may declare its own version; the platform-core
145            // version is the fallback (Java reads the app version from the
146            // build metadata, which a Rust library cannot see at runtime)
147            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
160/// One actuator endpoint (register with the shared [`ActuatorContext`]).
161pub 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                // Java ActuatorServices: explicit text/plain on the envelope
184                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                // Java handleInfoRoute: the app block plus the local routing
197                // table split by visibility, route → instance count. Java's
198                // optional blocks — "journal" (journaling), "route_substitution"
199                // and the mesh "network" table — are omitted when empty, and
200                // none of those subsystems exist in this port, so the response
201                // is exactly {app, routing}. BTreeMap keeps the output
202                // deterministic (Java's HashMap ordering is arbitrary; JSON
203                // object order is not contractual, but stable beats random).
204                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                // Java ActuatorServices: explicit application/json envelope type
215                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                // optional services never affect the overall status
266                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 }) // Java parity
291                    .set_header("content-type", "application/json")
292                    .set_body(serde_json::Value::Object(result))?)
293            }
294        }
295    }
296}
297
298/// The per-dependency info-lookup cache (Java parity:
299/// `SimpleCache.createCache("health.info", 5000)` in `ActuatorServices` —
300/// this port maps every Java `SimpleCache` site onto `ManagedCache`, per the
301/// maintainer's one-cache-type ruling; `draft-design-specs/managed-cache-port.md`).
302/// Only the `type=info` lookup is cached — never the `/health` result: the
303/// `type=health` probe re-runs on every call and `/livenessprobe` reads the
304/// atomic health flag.
305fn 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
310/// Query each health-check function: header `type=info` (3 s, cached 5 s per
311/// route — Java `isServiceUnhealthy`) merges its info map into the dependency
312/// entry, then `type=health` (10 s) decides the status (non-200 = down).
313/// Returns whether every service in the list is up.
314async 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        // info is advisory — merge whatever the service reports about itself.
326        // Java parity: the lookup is cached under "info/{route}" and only a
327        // map body is cached (a non-map response is re-requested every call)
328        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        // health decides the status
349        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
381// `elapsed_time` moved to `crate::util` (increment 71): it is now shared by
382// the `/info` uptime rendering here and the ManagedCache create log.\n\n/// The rendered local routing view, split by visibility with pool-style
383/// route families compressed. The routing table changes infrequently, so the
384/// rendered view is cached for 10 minutes to skip repeated computation under
385/// actuator polling — an ad-hoc runtime registration may take up to the
386/// window to appear, which is acceptable for the operator view (Java
387/// ActuatorServices parity).
388fn 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
418/// Render pool-style route families compactly (Java `ActuatorServices.
419/// compressRouteFamilies`): routes that differ only by a trailing numeric
420/// suffix, with uniform instances and contiguous canonical numbering (no
421/// leading zeros), collapse into one display entry — e.g. the 500 streaming
422/// reply lanes render as `"async.http.response.stream.0 - 499": 1`.
423/// Irregular families and singletons render individually with their names
424/// preserved exactly. Display-only — the routing table itself is unchanged.
425fn 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                // canonical digits only, so individual names are preserved exactly
438                (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}