1mod host;
2mod model;
3mod resolver;
4mod text;
5mod time;
6
7use crate::ic_registry::DEFAULT_MAINNET_ENDPOINT;
8use candid::Principal;
9pub(crate) use host::{
10 LiveNnsRegistryRefreshSource, SubnetCatalogCacheRequest, SubnetCatalogHostError,
11 SubnetCatalogRefreshRequest, SubnetCatalogRefreshSource, load_or_refresh_subnet_catalog,
12 refresh_subnet_catalog,
13};
14#[cfg(test)]
15pub(crate) use host::{
16 load_cached_subnet_catalog, refresh_subnet_catalog_with_source, subnet_catalog_path,
17 subnet_catalog_refresh_lock_path,
18};
19pub use model::{
20 ClassificationSource, GeographicScope, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
21 SubnetSpecialization,
22};
23pub use resolver::{ResolveAs, ResolvedSubnet, ResolvedSubnetSubject};
24use serde::{Deserialize, Serialize};
25#[cfg(test)]
26pub(crate) use text::compact_principal;
27pub(crate) use text::{
28 subnet_catalog_info_report_text, subnet_catalog_list_report_text,
29 subnet_catalog_list_report_verbose_text, subnet_catalog_refresh_report_text,
30};
31use thiserror::Error as ThisError;
32#[cfg(test)]
33pub(crate) use time::parse_stale_after_duration;
34pub(crate) use time::{catalog_stale_status, format_utc_timestamp_secs};
35
36pub const CATALOG_SCHEMA_VERSION: u32 = 1;
37pub const MAINNET_NETWORK: &str = "ic";
38pub const MAINNET_REGISTRY_CANISTER_ID: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
39pub(crate) const DEFAULT_STALE_AFTER_SECONDS: u64 = 7 * 24 * 60 * 60;
40pub(crate) const DEFAULT_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
41pub(crate) const DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
42pub(crate) const SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
43pub(crate) const SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
44pub(crate) const SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
45const BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS: u128 = 1_000_000_000;
46const FORMULA_VERSION: &str = "base_13_node_linear_v1";
47
48#[derive(Debug, ThisError)]
52pub enum CatalogError {
53 #[error(transparent)]
54 Json(#[from] serde_json::Error),
55
56 #[error("unsupported subnet catalog schema version {found}; supported version is {supported}")]
57 UnsupportedSchemaVersion { found: u32, supported: u32 },
58
59 #[error("subnet catalog must contain at least one subnet")]
60 EmptySubnets,
61
62 #[error("subnet catalog must contain at least one routing range")]
63 EmptyRoutingRanges,
64
65 #[error("invalid principal in {field}: {value}: {reason}")]
66 InvalidPrincipal {
67 field: &'static str,
68 value: String,
69 reason: String,
70 },
71
72 #[error("duplicate subnet principal in catalog: {subnet_principal}")]
73 DuplicateSubnet { subnet_principal: String },
74
75 #[error("routing range references unknown subnet: {subnet_principal}")]
76 UnknownRoutingSubnet { subnet_principal: String },
77
78 #[error(
79 "invalid routing range for {subnet_principal}: start {start_canister_id} sorts after end {end_canister_id}"
80 )]
81 InvalidRoutingRange {
82 subnet_principal: String,
83 start_canister_id: String,
84 end_canister_id: String,
85 },
86
87 #[error("subnet principal {subnet_principal} was not found in the cached catalog")]
88 UnknownSubnet { subnet_principal: String },
89
90 #[error("principal prefix {prefix:?} did not match cached subnet principals")]
91 PrincipalPrefixNotFound { prefix: String },
92
93 #[error("principal prefix {prefix:?} is ambiguous; matches: {matches:?}")]
94 AmbiguousPrincipalPrefix {
95 prefix: String,
96 matches: Vec<String>,
97 },
98
99 #[error(
100 "canister principal {canister_principal} was not covered by cached routing ranges at registry_version={registry_version}, catalog_schema_version={catalog_schema_version}"
101 )]
102 RouteNotFound {
103 canister_principal: String,
104 registry_version: u64,
105 catalog_schema_version: u32,
106 },
107}
108
109pub fn parse_catalog_json(data: &str) -> Result<SubnetCatalog, CatalogError> {
111 let catalog = serde_json::from_str::<SubnetCatalog>(data)?;
112 catalog.validate()?;
113 Ok(catalog)
114}
115
116pub fn catalog_to_pretty_json(catalog: &SubnetCatalog) -> Result<String, CatalogError> {
118 Ok(serde_json::to_string_pretty(catalog)?)
119}
120
121pub fn canonical_principal_text(value: &str) -> Result<String, CatalogError> {
123 Ok(parse_principal(value, "principal")?.to_text())
124}
125
126pub(crate) fn parse_principal(value: &str, field: &'static str) -> Result<Principal, CatalogError> {
127 Principal::from_text(value).map_err(|err| CatalogError::InvalidPrincipal {
128 field,
129 value: value.to_string(),
130 reason: err.to_string(),
131 })
132}
133
134pub(crate) fn principal_bytes(value: &str, field: &'static str) -> Result<Vec<u8>, CatalogError> {
135 Ok(parse_principal(value, field)?.as_slice().to_vec())
136}
137
138#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
142pub(crate) struct SubnetCatalogFilters {
143 pub kind: Option<SubnetKind>,
144 pub specialization: Option<SubnetSpecialization>,
145 pub geographic_scope: Option<GeographicScope>,
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
152pub(crate) struct SubnetCatalogListRequest {
153 pub cache: SubnetCatalogCacheRequest,
154 pub source_endpoint: String,
155 pub now_unix_secs: u64,
156 pub stale_after_seconds: u64,
157 pub filters: SubnetCatalogFilters,
158 pub show_ranges: bool,
159 pub range_limit: usize,
160 pub range_offset: usize,
161}
162
163#[derive(Clone, Debug, Eq, PartialEq)]
167pub(crate) struct SubnetCatalogInfoRequest {
168 pub cache: SubnetCatalogCacheRequest,
169 pub source_endpoint: String,
170 pub input: String,
171 pub forced: Option<ResolveAs>,
172 pub now_unix_secs: u64,
173 pub stale_after_seconds: u64,
174}
175
176#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180pub(crate) struct CatalogStaleStatus {
181 pub catalog_stale: bool,
182 pub stale_reason: String,
183 pub stale_after_seconds: u64,
184 pub fetched_at_unix_secs: Option<u64>,
185 pub age_seconds: Option<u64>,
186}
187
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub(crate) struct SubnetCatalogListReport {
193 pub schema_version: u32,
194 pub network: String,
195 pub catalog_path: String,
196 pub catalog_schema_version: u32,
197 pub registry_canister_id: String,
198 pub registry_version: u64,
199 pub fetched_at: String,
200 pub catalog_stale: bool,
201 pub stale_reason: String,
202 pub resolver_backend: String,
203 pub subnets: Vec<SubnetCatalogSubnetRow>,
204}
205
206#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
210pub(crate) struct SubnetCatalogSubnetRow {
211 pub subnet_principal: String,
212 pub subnet_kind: SubnetKind,
213 pub subnet_kind_source: ClassificationSource,
214 pub subnet_specialization: SubnetSpecialization,
215 pub subnet_specialization_source: ClassificationSource,
216 pub geographic_scope: GeographicScope,
217 pub geographic_scope_source: ClassificationSource,
218 pub subnet_label: String,
219 pub subnet_label_source: ClassificationSource,
220 pub node_count: Option<u32>,
221 pub charges_apply_by_default: bool,
222 pub range_count: usize,
223 pub ranges_shown: usize,
224 pub range_offset: usize,
225 pub range_limit: usize,
226 pub ranges: Vec<RoutingRange>,
227}
228
229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
233pub(crate) struct SubnetCatalogInfoReport {
234 pub schema_version: u32,
235 pub input_principal: String,
236 pub resolved_as: String,
237 pub resolved_from: String,
238 pub subnet_principal: String,
239 pub subnet_kind: SubnetKind,
240 pub subnet_kind_source: ClassificationSource,
241 pub subnet_specialization: SubnetSpecialization,
242 pub subnet_specialization_source: ClassificationSource,
243 pub geographic_scope: GeographicScope,
244 pub geographic_scope_source: ClassificationSource,
245 pub subnet_label: String,
246 pub subnet_label_source: ClassificationSource,
247 pub node_count: Option<u32>,
248 pub charges_apply_to_subject: bool,
249 pub charge_applicability_reason: String,
250 pub registry_canister_id: String,
251 pub registry_version: u64,
252 pub catalog_schema_version: u32,
253 pub catalog_path: String,
254 pub fetched_at: String,
255 pub catalog_stale: bool,
256 pub stale_reason: String,
257 pub resolver_backend: String,
258 pub matched_canister_principal: Option<String>,
259 pub matched_routing_range: Option<RoutingRange>,
260 pub cycles_per_billion_instructions: Option<u128>,
261 pub rate_source: Option<String>,
262 pub formula_version: Option<String>,
263}
264
265#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
269pub(crate) struct SubnetCatalogRefreshReport {
270 pub schema_version: u32,
271 pub network: String,
272 pub catalog_path: String,
273 pub refresh_lock_path: String,
274 pub output_path: Option<String>,
275 pub registry_canister_id: String,
276 pub registry_version: u64,
277 pub fetched_at: String,
278 pub source_endpoint: String,
279 pub fetched_by: String,
280 pub dry_run: bool,
281 pub wrote_catalog: bool,
282 pub replaced_existing_catalog: bool,
283 pub subnet_count: usize,
284 pub routing_range_count: usize,
285}
286
287pub(crate) fn build_subnet_catalog_list_report(
288 request: &SubnetCatalogListRequest,
289) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
290 build_subnet_catalog_list_report_with_source(request, &LiveNnsRegistryRefreshSource)
291}
292
293fn build_subnet_catalog_list_report_with_source(
294 request: &SubnetCatalogListRequest,
295 source: &dyn SubnetCatalogRefreshSource,
296) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
297 let cached = load_or_refresh_subnet_catalog(
298 &request.cache,
299 &request.source_endpoint,
300 request.now_unix_secs,
301 source,
302 )?;
303 let stale = catalog_stale_status(
304 &cached.catalog,
305 request.now_unix_secs,
306 request.stale_after_seconds,
307 );
308 let subnets = cached
309 .catalog
310 .subnets
311 .iter()
312 .filter(|subnet| subnet_matches_filters(subnet, request.filters))
313 .map(|subnet| subnet_row(&cached.catalog, subnet, request))
314 .collect::<Vec<_>>();
315
316 Ok(SubnetCatalogListReport {
317 schema_version: SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION,
318 network: cached.catalog.network,
319 catalog_path: cached.path.display().to_string(),
320 catalog_schema_version: cached.catalog.catalog_schema_version,
321 registry_canister_id: cached.catalog.registry_canister_id,
322 registry_version: cached.catalog.registry_version,
323 fetched_at: cached.catalog.fetched_at,
324 catalog_stale: stale.catalog_stale,
325 stale_reason: stale.stale_reason,
326 resolver_backend: cached.catalog.resolver_backend,
327 subnets,
328 })
329}
330
331pub(crate) fn build_subnet_catalog_info_report(
332 request: &SubnetCatalogInfoRequest,
333) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
334 build_subnet_catalog_info_report_with_source(request, &LiveNnsRegistryRefreshSource)
335}
336
337fn build_subnet_catalog_info_report_with_source(
338 request: &SubnetCatalogInfoRequest,
339 source: &dyn SubnetCatalogRefreshSource,
340) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
341 let cached = load_or_refresh_subnet_catalog(
342 &request.cache,
343 &request.source_endpoint,
344 request.now_unix_secs,
345 source,
346 )?;
347 let stale = catalog_stale_status(
348 &cached.catalog,
349 request.now_unix_secs,
350 request.stale_after_seconds,
351 );
352 let resolved = cached
353 .catalog
354 .resolve_principal_or_prefix(&request.input, request.forced)?;
355 let (charges_apply_to_subject, charge_applicability_reason) =
356 charge_applicability(resolved.resolved_as, resolved.subnet.subnet_kind);
357 let cycles_per_billion_instructions = catalog_cycles_per_billion(&resolved.subnet);
358 let rate_source = cycles_per_billion_instructions
359 .is_some()
360 .then(|| "nns-registry-cache".to_string());
361 let formula_version = cycles_per_billion_instructions
362 .is_some()
363 .then(|| FORMULA_VERSION.to_string());
364
365 Ok(SubnetCatalogInfoReport {
366 schema_version: SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION,
367 input_principal: resolved.input_principal,
368 resolved_as: resolved.resolved_as.as_str().to_string(),
369 resolved_from: resolved.resolved_from,
370 subnet_principal: resolved.subnet.subnet_principal,
371 subnet_kind: resolved.subnet.subnet_kind,
372 subnet_kind_source: resolved.subnet.subnet_kind_source,
373 subnet_specialization: resolved.subnet.subnet_specialization,
374 subnet_specialization_source: resolved.subnet.subnet_specialization_source,
375 geographic_scope: resolved.subnet.geographic_scope,
376 geographic_scope_source: resolved.subnet.geographic_scope_source,
377 subnet_label: resolved.subnet.subnet_label,
378 subnet_label_source: resolved.subnet.subnet_label_source,
379 node_count: resolved.subnet.node_count,
380 charges_apply_to_subject,
381 charge_applicability_reason,
382 registry_canister_id: cached.catalog.registry_canister_id,
383 registry_version: cached.catalog.registry_version,
384 catalog_schema_version: cached.catalog.catalog_schema_version,
385 catalog_path: cached.path.display().to_string(),
386 fetched_at: cached.catalog.fetched_at,
387 catalog_stale: stale.catalog_stale,
388 stale_reason: stale.stale_reason,
389 resolver_backend: cached.catalog.resolver_backend,
390 matched_canister_principal: resolved.matched_canister_principal,
391 matched_routing_range: resolved.matched_routing_range,
392 cycles_per_billion_instructions,
393 rate_source,
394 formula_version,
395 })
396}
397
398fn subnet_matches_filters(subnet: &SubnetInfo, filters: SubnetCatalogFilters) -> bool {
399 filters.kind.is_none_or(|kind| subnet.subnet_kind == kind)
400 && filters
401 .specialization
402 .is_none_or(|specialization| subnet.subnet_specialization == specialization)
403 && filters
404 .geographic_scope
405 .is_none_or(|scope| subnet.geographic_scope == scope)
406}
407
408fn subnet_row(
409 catalog: &SubnetCatalog,
410 subnet: &SubnetInfo,
411 request: &SubnetCatalogListRequest,
412) -> SubnetCatalogSubnetRow {
413 let ranges = catalog.routing_ranges_for_subnet(&subnet.subnet_principal);
414 let range_count = ranges.len();
415 let shown_ranges = if request.show_ranges {
416 ranges
417 .into_iter()
418 .skip(request.range_offset)
419 .take(request.range_limit)
420 .cloned()
421 .collect::<Vec<_>>()
422 } else {
423 Vec::new()
424 };
425 SubnetCatalogSubnetRow {
426 subnet_principal: subnet.subnet_principal.clone(),
427 subnet_kind: subnet.subnet_kind,
428 subnet_kind_source: subnet.subnet_kind_source,
429 subnet_specialization: subnet.subnet_specialization,
430 subnet_specialization_source: subnet.subnet_specialization_source,
431 geographic_scope: subnet.geographic_scope,
432 geographic_scope_source: subnet.geographic_scope_source,
433 subnet_label: subnet.subnet_label.clone(),
434 subnet_label_source: subnet.subnet_label_source,
435 node_count: subnet.node_count,
436 charges_apply_by_default: subnet.charges_apply_by_default,
437 range_count,
438 ranges_shown: shown_ranges.len(),
439 range_offset: request.range_offset,
440 range_limit: request.range_limit,
441 ranges: shown_ranges,
442 }
443}
444
445fn charge_applicability(subject: ResolvedSubnetSubject, kind: SubnetKind) -> (bool, String) {
446 match kind {
447 SubnetKind::Application | SubnetKind::CloudEngine => {
448 (true, "charged_user_canister_subnet".to_string())
449 }
450 SubnetKind::System if subject == ResolvedSubnetSubject::Subnet => {
451 (false, "system_subnet_core_canister".to_string())
452 }
453 SubnetKind::System => (false, "system_subnet_unknown_subject".to_string()),
454 SubnetKind::Unknown => (false, "unknown_subnet_type".to_string()),
455 }
456}
457
458fn catalog_cycles_per_billion(subnet: &SubnetInfo) -> Option<u128> {
459 if !subnet.subnet_kind.charges_apply_by_default() {
460 return None;
461 }
462 let node_count = u128::from(subnet.node_count?);
463 if node_count == 0 {
464 return None;
465 }
466 Some(ceil_div(
467 BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS * node_count,
468 13,
469 ))
470}
471
472const fn ceil_div(numerator: u128, denominator: u128) -> u128 {
473 numerator.div_ceil(denominator)
474}
475
476#[cfg(test)]
477mod core_tests;
478#[cfg(test)]
479mod tests;