Skip to main content

canic_host/observability/
mod.rs

1//! Module: observability
2//!
3//! Responsibility: route exact Fleet telemetry through controller-authenticated Canic surfaces.
4//! Does not own: Fleet discovery, metric interpretation, or report rendering.
5//! Boundary: Root observations query the Root directly; descendants use its protected relay.
6
7use crate::{
8    CanisterProtocolError, call_canister_with_arg,
9    fleet_ensure::CurrentFleetResolution,
10    icp::IcpCli,
11    protocol_binding::{ProtocolBindingError, resolve_registry_protocol_binding},
12    query_canister_with_arg,
13    registry::RegistryEntry,
14};
15use candid::{CandidType, Deserialize, Principal, types::principal::PrincipalError};
16use canic_core::{
17    dto::{
18        canister::{CanisterInspectionRequest, CanisterStatusResponse},
19        observability::{
20            CanisterObservabilityRequest, CanisterObservabilityResponse,
21            FleetCanisterObservabilityRequest,
22        },
23        page::Page,
24    },
25    ids::CanisterRole,
26    protocol,
27};
28use std::{collections::BTreeSet, path::Path};
29use thiserror::Error as ThisError;
30
31#[derive(CandidType)]
32enum RootCommandFragment {
33    InspectCanister(CanisterInspectionRequest),
34    ObserveCanister(FleetCanisterObservabilityRequest),
35}
36
37#[derive(CandidType, Deserialize)]
38enum RootCommandResponseFragment {
39    InspectCanister(Box<CanisterStatusResponse>),
40    InspectionReserveRequired(canic_core::dto::canister::CanisterInspectionReserveResponse),
41    ObserveCanister(CanisterObservabilityResponse),
42}
43
44#[derive(CandidType)]
45enum RootStatusRequestFragment {
46    ChildFunding(Principal),
47    CycleBalance,
48    CycleHistory(canic_core::dto::page::PageRequest),
49    MemoryAllocations,
50    Metrics(canic_core::dto::role::MetricsStatusRequest),
51}
52
53#[derive(CandidType, Deserialize)]
54enum RootStatusResponseFragment {
55    ChildFunding(canic_core::dto::observability::ChildFundingUsage),
56    CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
57    CycleHistory(Page<canic_core::dto::cycles::CycleTrackerEntry>),
58    MemoryAllocations(canic_core::dto::memory::MemoryAllocationsResponse),
59    Metrics(Page<canic_core::dto::metrics::MetricEntry>),
60}
61
62#[derive(CandidType)]
63enum StoreStatusRequestFragment {
64    CycleHistory(canic_core::dto::page::PageRequest),
65}
66
67#[derive(CandidType, Deserialize)]
68enum StoreStatusResponseFragment {
69    CycleHistory(Page<canic_core::dto::cycles::CycleTrackerEntry>),
70}
71
72/// Failure to route one protected observation through exact current Fleet authority.
73#[derive(Debug, ThisError)]
74pub enum FleetObservabilityError {
75    #[error("Canister {canister} has no owning Fleet Subnet Root in current topology")]
76    MissingRoot { canister: String },
77
78    #[error("current Fleet observability topology contains a parent cycle at {canister}")]
79    ParentCycle { canister: String },
80
81    #[error("invalid {field} Principal {value}: {source}")]
82    Principal {
83        field: &'static str,
84        value: String,
85        #[source]
86        source: PrincipalError,
87    },
88
89    #[error(transparent)]
90    Protocol(#[from] CanisterProtocolError),
91
92    #[error(transparent)]
93    ProtocolBinding(#[from] ProtocolBindingError),
94
95    #[error("Fleet Subnet Root does not expose cycle top-up history")]
96    RootCycleTopupsUnsupported,
97
98    #[error("Wasm Store does not maintain child funding grants")]
99    StoreChildFundingUnsupported,
100
101    #[error("Wasm Store does not expose cycle top-up history")]
102    StoreCycleTopupsUnsupported,
103
104    #[error("Wasm Store does not expose current memory allocations")]
105    StoreMemoryAllocationsUnsupported,
106
107    #[error("Wasm Store does not expose runtime metrics")]
108    StoreMetricsUnsupported,
109
110    #[error("Fleet Subnet Root returned a different observability response variant")]
111    UnexpectedRootResponse,
112
113    #[error("Canister {canister} cycle balance exceeds u128")]
114    CycleBalanceOverflow { canister: String },
115}
116
117/// Observe one current Fleet canister without granting its human operator lifecycle control.
118pub fn observe_fleet_canister(
119    icp: &IcpCli,
120    icp_root: &Path,
121    environment: &str,
122    fleet: &CurrentFleetResolution,
123    entry: &RegistryEntry,
124    request: CanisterObservabilityRequest,
125) -> Result<CanisterObservabilityResponse, FleetObservabilityError> {
126    if entry.role.as_deref() == Some(CanisterRole::ROOT.as_str()) {
127        return observe_root(icp, icp_root, environment, entry, request);
128    }
129
130    let root = fleet_subnet_root_entry(&fleet.registry.entries, entry)?;
131    let binding = resolve_registry_protocol_binding(icp_root, environment, root)?;
132    let root_canister = parse_principal("Fleet Subnet Root", &root.pid)?;
133    let target = parse_principal("observability target", &entry.pid)?;
134    if matches!(&request, CanisterObservabilityRequest::CycleBalance) {
135        crate::canister_protocol::inspection::preflight_inspection(
136            icp,
137            &binding.candid_path,
138            root_canister,
139            target,
140        )?;
141        let response: RootCommandResponseFragment = call_canister_with_arg(
142            icp,
143            &binding,
144            root_canister,
145            protocol::CANIC_ROOT_COMMAND,
146            &RootCommandFragment::InspectCanister(CanisterInspectionRequest {
147                canister_id: target,
148            }),
149        )?;
150        let response = match response {
151            RootCommandResponseFragment::InspectCanister(response) => response,
152            RootCommandResponseFragment::InspectionReserveRequired(evidence) => {
153                return Err(CanisterProtocolError::inspection_reserve(
154                    root_canister,
155                    target,
156                    evidence,
157                )
158                .into());
159            }
160            RootCommandResponseFragment::ObserveCanister(_) => {
161                return Err(FleetObservabilityError::UnexpectedRootResponse);
162            }
163        };
164        let cycles = u128::try_from(response.cycles.0).map_err(|_| {
165            FleetObservabilityError::CycleBalanceOverflow {
166                canister: entry.pid.clone(),
167            }
168        })?;
169        return Ok(CanisterObservabilityResponse::CycleBalance(
170            canic_core::dto::role::CycleBalanceStatusResponse { cycles },
171        ));
172    }
173    if entry.role.as_deref() == Some(CanisterRole::WASM_STORE.as_str()) {
174        return observe_store(icp, icp_root, environment, entry, request);
175    }
176    let expected_child = child_funding_target(&request);
177    let response: RootCommandResponseFragment = call_canister_with_arg(
178        icp,
179        &binding,
180        root_canister,
181        protocol::CANIC_ROOT_COMMAND,
182        &RootCommandFragment::ObserveCanister(FleetCanisterObservabilityRequest {
183            canister_id: target,
184            request,
185        }),
186    )?;
187    let RootCommandResponseFragment::ObserveCanister(response) = response else {
188        return Err(FleetObservabilityError::UnexpectedRootResponse);
189    };
190    verify_child_funding_response(response, target, expected_child)
191}
192
193fn observe_store(
194    icp: &IcpCli,
195    icp_root: &Path,
196    environment: &str,
197    store: &RegistryEntry,
198    request: CanisterObservabilityRequest,
199) -> Result<CanisterObservabilityResponse, FleetObservabilityError> {
200    let request = match request {
201        CanisterObservabilityRequest::ChildFunding(_) => {
202            return Err(FleetObservabilityError::StoreChildFundingUnsupported);
203        }
204        CanisterObservabilityRequest::CycleHistory(page) => {
205            StoreStatusRequestFragment::CycleHistory(page)
206        }
207        CanisterObservabilityRequest::CycleTopups(_) => {
208            return Err(FleetObservabilityError::StoreCycleTopupsUnsupported);
209        }
210        CanisterObservabilityRequest::Metrics(_) => {
211            return Err(FleetObservabilityError::StoreMetricsUnsupported);
212        }
213        CanisterObservabilityRequest::MemoryAllocations => {
214            return Err(FleetObservabilityError::StoreMemoryAllocationsUnsupported);
215        }
216        CanisterObservabilityRequest::CycleBalance => {
217            unreachable!("Store CycleBalance uses Root management inspection");
218        }
219    };
220    let binding = resolve_registry_protocol_binding(icp_root, environment, store)?;
221    let store_canister = parse_principal("Wasm Store", &store.pid)?;
222    let response: StoreStatusResponseFragment = query_canister_with_arg(
223        icp,
224        &binding,
225        store_canister,
226        protocol::CANIC_OBSERVABILITY,
227        &request,
228    )?;
229    let StoreStatusResponseFragment::CycleHistory(page) = response;
230    Ok(CanisterObservabilityResponse::CycleHistory(page))
231}
232
233fn observe_root(
234    icp: &IcpCli,
235    icp_root: &Path,
236    environment: &str,
237    root: &RegistryEntry,
238    request: CanisterObservabilityRequest,
239) -> Result<CanisterObservabilityResponse, FleetObservabilityError> {
240    let expected_child = child_funding_target(&request);
241    let request = match request {
242        CanisterObservabilityRequest::ChildFunding(child) => {
243            RootStatusRequestFragment::ChildFunding(child)
244        }
245        CanisterObservabilityRequest::CycleBalance => RootStatusRequestFragment::CycleBalance,
246        CanisterObservabilityRequest::CycleHistory(page) => {
247            RootStatusRequestFragment::CycleHistory(page)
248        }
249        CanisterObservabilityRequest::CycleTopups(_) => {
250            return Err(FleetObservabilityError::RootCycleTopupsUnsupported);
251        }
252        CanisterObservabilityRequest::Metrics(request) => {
253            RootStatusRequestFragment::Metrics(request)
254        }
255        CanisterObservabilityRequest::MemoryAllocations => {
256            RootStatusRequestFragment::MemoryAllocations
257        }
258    };
259    let binding = resolve_registry_protocol_binding(icp_root, environment, root)?;
260    let root_canister = parse_principal("Fleet Subnet Root", &root.pid)?;
261    let response: RootStatusResponseFragment = query_canister_with_arg(
262        icp,
263        &binding,
264        root_canister,
265        protocol::CANIC_OBSERVABILITY,
266        &request,
267    )?;
268    let response = match response {
269        RootStatusResponseFragment::ChildFunding(response) => {
270            CanisterObservabilityResponse::ChildFunding(response)
271        }
272        RootStatusResponseFragment::CycleBalance(response) => {
273            CanisterObservabilityResponse::CycleBalance(response)
274        }
275        RootStatusResponseFragment::CycleHistory(response) => {
276            CanisterObservabilityResponse::CycleHistory(response)
277        }
278        RootStatusResponseFragment::Metrics(response) => {
279            CanisterObservabilityResponse::Metrics(response)
280        }
281        RootStatusResponseFragment::MemoryAllocations(response) => {
282            CanisterObservabilityResponse::MemoryAllocations(response)
283        }
284    };
285    verify_child_funding_response(response, root_canister, expected_child)
286}
287
288const fn child_funding_target(request: &CanisterObservabilityRequest) -> Option<Principal> {
289    match request {
290        CanisterObservabilityRequest::ChildFunding(child) => Some(*child),
291        _ => None,
292    }
293}
294
295fn verify_child_funding_response(
296    response: CanisterObservabilityResponse,
297    parent: Principal,
298    child: Option<Principal>,
299) -> Result<CanisterObservabilityResponse, FleetObservabilityError> {
300    if let Some(child) = child {
301        let CanisterObservabilityResponse::ChildFunding(value) = &response else {
302            return Err(FleetObservabilityError::UnexpectedRootResponse);
303        };
304        if (value.parent, value.child) != (parent, child) {
305            return Err(FleetObservabilityError::UnexpectedRootResponse);
306        }
307    }
308    Ok(response)
309}
310
311fn fleet_subnet_root_entry<'a>(
312    registry: &'a [RegistryEntry],
313    entry: &RegistryEntry,
314) -> Result<&'a RegistryEntry, FleetObservabilityError> {
315    let mut current = entry;
316    let mut visited = BTreeSet::new();
317    loop {
318        if !visited.insert(current.pid.as_str()) {
319            return Err(FleetObservabilityError::ParentCycle {
320                canister: current.pid.clone(),
321            });
322        }
323        if current.role.as_deref() == Some(CanisterRole::ROOT.as_str()) {
324            return registry
325                .iter()
326                .find(|candidate| candidate.pid == current.pid)
327                .ok_or_else(|| FleetObservabilityError::MissingRoot {
328                    canister: entry.pid.clone(),
329                });
330        }
331        let Some(parent) = current.parent_pid.as_deref() else {
332            return Err(FleetObservabilityError::MissingRoot {
333                canister: entry.pid.clone(),
334            });
335        };
336        current = registry
337            .iter()
338            .find(|candidate| candidate.pid == parent)
339            .ok_or_else(|| FleetObservabilityError::MissingRoot {
340                canister: entry.pid.clone(),
341            })?;
342    }
343}
344
345fn parse_principal(field: &'static str, value: &str) -> Result<Principal, FleetObservabilityError> {
346    Principal::from_text(value).map_err(|source| FleetObservabilityError::Principal {
347        field,
348        value: value.to_string(),
349        source,
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn child_funding_response_binds_parent_and_child_for_direct_and_relayed_reads() {
359        let parent = Principal::from_slice(&[1]);
360        let child = Principal::from_slice(&[2]);
361        let usage = canic_core::dto::observability::ChildFundingUsage {
362            parent,
363            child,
364            observed_at_ns: 10,
365            accounted_cycles: 0.into(),
366            last_accounted_at_secs: 0,
367            pending_operations: 0,
368            reserved_cycles: Some(0.into()),
369        };
370        assert!(
371            verify_child_funding_response(
372                CanisterObservabilityResponse::ChildFunding(usage.clone()),
373                parent,
374                Some(child)
375            )
376            .is_ok()
377        );
378        for (wrong_parent, wrong_child) in [(child, child), (parent, parent)] {
379            assert!(matches!(
380                verify_child_funding_response(
381                    CanisterObservabilityResponse::ChildFunding(usage.clone()),
382                    wrong_parent,
383                    Some(wrong_child)
384                ),
385                Err(FleetObservabilityError::UnexpectedRootResponse)
386            ));
387        }
388    }
389
390    fn entry(pid: &str, role: &str, parent: Option<&str>) -> RegistryEntry {
391        RegistryEntry {
392            pid: pid.to_string(),
393            role: Some(role.to_string()),
394            parent_pid: parent.map(str::to_string),
395            module_hash: None,
396            protocol_binding: None,
397        }
398    }
399
400    #[test]
401    fn descendant_observability_resolves_the_exact_owning_root() {
402        let coordinator = entry("coordinator", "fleet_coordinator", None);
403        let root = entry("root", "root", Some("coordinator"));
404        let parent = entry("parent", "hub", Some("root"));
405        let child = entry("child", "leaf", Some("parent"));
406        let registry = vec![coordinator, root.clone(), parent, child.clone()];
407
408        assert_eq!(
409            fleet_subnet_root_entry(&registry, &child)
410                .expect("owning Root")
411                .pid,
412            root.pid
413        );
414    }
415}