Skip to main content

ic_query/subnet_catalog/
mod.rs

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///
49/// CatalogError
50///
51#[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
109/// Decode and validate one subnet catalog JSON payload.
110pub 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
116/// Render one subnet catalog JSON payload with stable pretty formatting.
117pub fn catalog_to_pretty_json(catalog: &SubnetCatalog) -> Result<String, CatalogError> {
118    Ok(serde_json::to_string_pretty(catalog)?)
119}
120
121/// Parse a textual IC principal into canonical text.
122pub 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///
139/// SubnetCatalogFilters
140///
141#[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///
149/// SubnetCatalogListRequest
150///
151#[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///
164/// SubnetCatalogInfoRequest
165///
166#[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///
177/// CatalogStaleStatus
178///
179#[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///
189/// SubnetCatalogListReport
190///
191#[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///
207/// SubnetCatalogSubnetRow
208///
209#[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///
230/// SubnetCatalogInfoReport
231///
232#[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///
266/// SubnetCatalogRefreshReport
267///
268#[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)]
477#[path = "tests/core.rs"]
478mod core_tests;
479#[cfg(test)]
480mod tests;