1use 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#[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
117pub 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(®istry, &child)
410 .expect("owning Root")
411 .pid,
412 root.pid
413 );
414 }
415}