1mod model;
2mod resolver;
3
4#[cfg(test)]
5use crate::duration::parse_duration_seconds;
6use crate::ic_registry::{
7 DEFAULT_MAINNET_ENDPOINT, MainnetRegistryFetchRequest, RegistryFetchError,
8 fetch_mainnet_subnet_catalog,
9};
10use crate::{
11 cache_file::{
12 CacheFileError, RefreshLockRequest, acquire_refresh_lock, announce_cache_refresh,
13 create_directory, write_text_atomically, write_text_output,
14 },
15 nns::render::yes_no,
16 table::{ColumnAlign, render_table},
17};
18use candid::Principal;
19pub use model::{
20 ClassificationSource, GeographicScope, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
21 SubnetSpecialization,
22};
23pub use resolver::{ResolveAs, ResolvedSubnet, ResolvedSubnetSubject};
24use serde::{Deserialize, Serialize};
25use std::{
26 fs, io,
27 path::{Path, PathBuf},
28};
29use thiserror::Error as ThisError;
30
31pub const CATALOG_SCHEMA_VERSION: u32 = 1;
32pub const MAINNET_NETWORK: &str = "ic";
33pub const MAINNET_REGISTRY_CANISTER_ID: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
34pub(crate) const DEFAULT_STALE_AFTER_SECONDS: u64 = 7 * 24 * 60 * 60;
35pub(crate) const DEFAULT_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
36pub(crate) const DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
37pub(crate) const SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
38pub(crate) const SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
39pub(crate) const SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
40const BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS: u128 = 1_000_000_000;
41const FORMULA_VERSION: &str = "base_13_node_linear_v1";
42
43#[derive(Debug, ThisError)]
47pub enum CatalogError {
48 #[error(transparent)]
49 Json(#[from] serde_json::Error),
50
51 #[error("unsupported subnet catalog schema version {found}; supported version is {supported}")]
52 UnsupportedSchemaVersion { found: u32, supported: u32 },
53
54 #[error("subnet catalog must contain at least one subnet")]
55 EmptySubnets,
56
57 #[error("subnet catalog must contain at least one routing range")]
58 EmptyRoutingRanges,
59
60 #[error("invalid principal in {field}: {value}: {reason}")]
61 InvalidPrincipal {
62 field: &'static str,
63 value: String,
64 reason: String,
65 },
66
67 #[error("duplicate subnet principal in catalog: {subnet_principal}")]
68 DuplicateSubnet { subnet_principal: String },
69
70 #[error("routing range references unknown subnet: {subnet_principal}")]
71 UnknownRoutingSubnet { subnet_principal: String },
72
73 #[error(
74 "invalid routing range for {subnet_principal}: start {start_canister_id} sorts after end {end_canister_id}"
75 )]
76 InvalidRoutingRange {
77 subnet_principal: String,
78 start_canister_id: String,
79 end_canister_id: String,
80 },
81
82 #[error("subnet principal {subnet_principal} was not found in the cached catalog")]
83 UnknownSubnet { subnet_principal: String },
84
85 #[error("principal prefix {prefix:?} did not match cached subnet principals")]
86 PrincipalPrefixNotFound { prefix: String },
87
88 #[error("principal prefix {prefix:?} is ambiguous; matches: {matches:?}")]
89 AmbiguousPrincipalPrefix {
90 prefix: String,
91 matches: Vec<String>,
92 },
93
94 #[error(
95 "canister principal {canister_principal} was not covered by cached routing ranges at registry_version={registry_version}, catalog_schema_version={catalog_schema_version}"
96 )]
97 RouteNotFound {
98 canister_principal: String,
99 registry_version: u64,
100 catalog_schema_version: u32,
101 },
102}
103
104pub fn parse_catalog_json(data: &str) -> Result<SubnetCatalog, CatalogError> {
106 let catalog = serde_json::from_str::<SubnetCatalog>(data)?;
107 catalog.validate()?;
108 Ok(catalog)
109}
110
111pub fn catalog_to_pretty_json(catalog: &SubnetCatalog) -> Result<String, CatalogError> {
113 Ok(serde_json::to_string_pretty(catalog)?)
114}
115
116pub fn canonical_principal_text(value: &str) -> Result<String, CatalogError> {
118 Ok(parse_principal(value, "principal")?.to_text())
119}
120
121pub(crate) fn parse_principal(value: &str, field: &'static str) -> Result<Principal, CatalogError> {
122 Principal::from_text(value).map_err(|err| CatalogError::InvalidPrincipal {
123 field,
124 value: value.to_string(),
125 reason: err.to_string(),
126 })
127}
128
129pub(crate) fn principal_bytes(value: &str, field: &'static str) -> Result<Vec<u8>, CatalogError> {
130 Ok(parse_principal(value, field)?.as_slice().to_vec())
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
137pub(crate) struct SubnetCatalogCacheRequest {
138 pub icp_root: PathBuf,
139 pub network: String,
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
146pub(crate) struct CachedSubnetCatalog {
147 pub path: PathBuf,
148 pub catalog: SubnetCatalog,
149}
150
151#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
155pub(crate) struct SubnetCatalogFilters {
156 pub kind: Option<SubnetKind>,
157 pub specialization: Option<SubnetSpecialization>,
158 pub geographic_scope: Option<GeographicScope>,
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
165pub(crate) struct SubnetCatalogListRequest {
166 pub cache: SubnetCatalogCacheRequest,
167 pub source_endpoint: String,
168 pub now_unix_secs: u64,
169 pub stale_after_seconds: u64,
170 pub filters: SubnetCatalogFilters,
171 pub show_ranges: bool,
172 pub range_limit: usize,
173 pub range_offset: usize,
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
180pub(crate) struct SubnetCatalogInfoRequest {
181 pub cache: SubnetCatalogCacheRequest,
182 pub source_endpoint: String,
183 pub input: String,
184 pub forced: Option<ResolveAs>,
185 pub now_unix_secs: u64,
186 pub stale_after_seconds: u64,
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
193pub(crate) struct SubnetCatalogRefreshRequest {
194 pub cache: SubnetCatalogCacheRequest,
195 pub source_endpoint: String,
196 pub now_unix_secs: u64,
197 pub lock_stale_after_seconds: u64,
198 pub dry_run: bool,
199 pub output_path: Option<PathBuf>,
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub(crate) struct CatalogStaleStatus {
207 pub catalog_stale: bool,
208 pub stale_reason: String,
209 pub stale_after_seconds: u64,
210 pub fetched_at_unix_secs: Option<u64>,
211 pub age_seconds: Option<u64>,
212}
213
214#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub(crate) struct SubnetCatalogListReport {
219 pub schema_version: u32,
220 pub network: String,
221 pub catalog_path: String,
222 pub catalog_schema_version: u32,
223 pub registry_canister_id: String,
224 pub registry_version: u64,
225 pub fetched_at: String,
226 pub catalog_stale: bool,
227 pub stale_reason: String,
228 pub resolver_backend: String,
229 pub subnets: Vec<SubnetCatalogSubnetRow>,
230}
231
232#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub(crate) struct SubnetCatalogSubnetRow {
237 pub subnet_principal: String,
238 pub subnet_kind: SubnetKind,
239 pub subnet_kind_source: ClassificationSource,
240 pub subnet_specialization: SubnetSpecialization,
241 pub subnet_specialization_source: ClassificationSource,
242 pub geographic_scope: GeographicScope,
243 pub geographic_scope_source: ClassificationSource,
244 pub subnet_label: String,
245 pub subnet_label_source: ClassificationSource,
246 pub node_count: Option<u32>,
247 pub charges_apply_by_default: bool,
248 pub range_count: usize,
249 pub ranges_shown: usize,
250 pub range_offset: usize,
251 pub range_limit: usize,
252 pub ranges: Vec<RoutingRange>,
253}
254
255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259pub(crate) struct SubnetCatalogInfoReport {
260 pub schema_version: u32,
261 pub input_principal: String,
262 pub resolved_as: String,
263 pub resolved_from: String,
264 pub subnet_principal: String,
265 pub subnet_kind: SubnetKind,
266 pub subnet_kind_source: ClassificationSource,
267 pub subnet_specialization: SubnetSpecialization,
268 pub subnet_specialization_source: ClassificationSource,
269 pub geographic_scope: GeographicScope,
270 pub geographic_scope_source: ClassificationSource,
271 pub subnet_label: String,
272 pub subnet_label_source: ClassificationSource,
273 pub node_count: Option<u32>,
274 pub charges_apply_to_subject: bool,
275 pub charge_applicability_reason: String,
276 pub registry_canister_id: String,
277 pub registry_version: u64,
278 pub catalog_schema_version: u32,
279 pub catalog_path: String,
280 pub fetched_at: String,
281 pub catalog_stale: bool,
282 pub stale_reason: String,
283 pub resolver_backend: String,
284 pub matched_canister_principal: Option<String>,
285 pub matched_routing_range: Option<RoutingRange>,
286 pub cycles_per_billion_instructions: Option<u128>,
287 pub rate_source: Option<String>,
288 pub formula_version: Option<String>,
289}
290
291#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
295pub(crate) struct SubnetCatalogRefreshReport {
296 pub schema_version: u32,
297 pub network: String,
298 pub catalog_path: String,
299 pub refresh_lock_path: String,
300 pub output_path: Option<String>,
301 pub registry_canister_id: String,
302 pub registry_version: u64,
303 pub fetched_at: String,
304 pub source_endpoint: String,
305 pub fetched_by: String,
306 pub dry_run: bool,
307 pub wrote_catalog: bool,
308 pub replaced_existing_catalog: bool,
309 pub subnet_count: usize,
310 pub routing_range_count: usize,
311}
312
313#[derive(Debug, ThisError)]
317pub(crate) enum SubnetCatalogHostError {
318 #[error(
319 "`icq nns subnet` supports only the mainnet `ic` network\n\nThe cached NNS subnet data describes the public Internet Computer mainnet.\nLocal replica subnet discovery is not implemented yet.\n\nTry:\n icq --network ic nns subnet list"
320 )]
321 UnsupportedNetwork { network: String },
322
323 #[error(
324 "subnet catalog cache is missing at {}\n\nRun `icq nns subnet refresh` to fetch the public Internet Computer mainnet catalog, or populate this path with a valid subnet catalog JSON.",
325 path.display()
326 )]
327 MissingCatalog { path: PathBuf },
328
329 #[error("failed to read subnet catalog at {}: {source}", path.display())]
330 ReadCatalog { path: PathBuf, source: io::Error },
331
332 #[error(
333 "cached subnet catalog network mismatch: path is for {requested}, catalog is for {actual}"
334 )]
335 NetworkMismatch { requested: String, actual: String },
336
337 #[error(
338 "invalid stale duration {value:?}; use positive seconds or a value ending in s, m, h, or d"
339 )]
340 #[cfg(test)]
341 InvalidStaleDuration { value: String },
342
343 #[error("subnet catalog refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
344 RefreshAlreadyInProgress {
345 path: PathBuf,
346 started_at_unix_ms: u64,
347 },
348
349 #[error("failed to create subnet catalog directory at {}: {source}", path.display())]
350 CreateCatalogDirectory { path: PathBuf, source: io::Error },
351
352 #[error("failed to create refresh lock at {}: {source}", path.display())]
353 CreateRefreshLock { path: PathBuf, source: io::Error },
354
355 #[error("failed to read refresh lock at {}: {source}", path.display())]
356 ReadRefreshLock { path: PathBuf, source: io::Error },
357
358 #[error("failed to parse refresh lock at {}: {source}", path.display())]
359 ParseRefreshLock {
360 path: PathBuf,
361 source: serde_json::Error,
362 },
363
364 #[error("failed to write refresh lock at {}: {source}", path.display())]
365 WriteRefreshLock { path: PathBuf, source: io::Error },
366
367 #[error("failed to remove refresh lock at {}: {source}", path.display())]
368 RemoveRefreshLock { path: PathBuf, source: io::Error },
369
370 #[error("live NNS registry refresh failed: {0}")]
371 RegistryRefresh(#[from] RegistryFetchError),
372
373 #[error("refreshed subnet catalog network mismatch: requested {requested}, fetched {actual}")]
374 RefreshNetworkMismatch { requested: String, actual: String },
375
376 #[error("failed to write subnet catalog temp file at {}: {source}", path.display())]
377 WriteCatalogTemp { path: PathBuf, source: io::Error },
378
379 #[error("failed to sync subnet catalog temp file at {}: {source}", path.display())]
380 SyncCatalogTemp { path: PathBuf, source: io::Error },
381
382 #[error("failed to replace subnet catalog at {} from {}: {source}", catalog_path.display(), temp_path.display())]
383 ReplaceCatalog {
384 temp_path: PathBuf,
385 catalog_path: PathBuf,
386 source: io::Error,
387 },
388
389 #[error("failed to sync subnet catalog directory at {}: {source}", path.display())]
390 SyncCatalogDirectory { path: PathBuf, source: io::Error },
391
392 #[error("failed to write refreshed subnet catalog output at {}: {source}", path.display())]
393 WriteRefreshOutput { path: PathBuf, source: io::Error },
394
395 #[error("failed to sync refreshed subnet catalog output at {}: {source}", path.display())]
396 SyncRefreshOutput { path: PathBuf, source: io::Error },
397
398 #[error(transparent)]
399 Catalog(#[from] CatalogError),
400}
401
402#[must_use]
403pub(crate) fn subnet_catalog_path(icp_root: &Path, network: &str) -> PathBuf {
404 icp_root
405 .join(".icq")
406 .join("subnet-catalog")
407 .join(network)
408 .join("catalog.json")
409}
410
411#[must_use]
412pub(crate) fn subnet_catalog_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
413 icp_root
414 .join(".icq")
415 .join("subnet-catalog")
416 .join(network)
417 .join("refresh.lock")
418}
419
420pub(crate) fn load_cached_subnet_catalog(
421 request: &SubnetCatalogCacheRequest,
422) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
423 enforce_mainnet_network(&request.network)?;
424 let path = subnet_catalog_path(&request.icp_root, &request.network);
425 if !path.is_file() {
426 return Err(SubnetCatalogHostError::MissingCatalog { path });
427 }
428 let data = fs::read_to_string(&path).map_err(|source| SubnetCatalogHostError::ReadCatalog {
429 path: path.clone(),
430 source,
431 })?;
432 let catalog = parse_catalog_json(&data)?;
433 if catalog.network != request.network {
434 return Err(SubnetCatalogHostError::NetworkMismatch {
435 requested: request.network.clone(),
436 actual: catalog.network,
437 });
438 }
439 Ok(CachedSubnetCatalog { path, catalog })
440}
441
442pub(crate) fn refresh_subnet_catalog(
443 request: &SubnetCatalogRefreshRequest,
444) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
445 refresh_subnet_catalog_with_source(request, &LiveNnsRegistryRefreshSource)
446}
447
448fn refresh_subnet_catalog_with_source(
449 request: &SubnetCatalogRefreshRequest,
450 source: &dyn SubnetCatalogRefreshSource,
451) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
452 enforce_mainnet_network(&request.cache.network)?;
453 let catalog_path = subnet_catalog_path(&request.cache.icp_root, &request.cache.network);
454 let lock_path =
455 subnet_catalog_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
456 let catalog_dir = catalog_path
457 .parent()
458 .expect("subnet catalog path always has parent")
459 .to_path_buf();
460 create_directory(&catalog_dir).map_err(subnet_cache_error)?;
461 let lock = acquire_refresh_lock(RefreshLockRequest {
462 lock_path: &lock_path,
463 target_path: &catalog_path,
464 network: &request.cache.network,
465 now_unix_secs: request.now_unix_secs,
466 lock_stale_after_seconds: request.lock_stale_after_seconds,
467 })
468 .map_err(subnet_cache_error)?;
469 let replaced_existing_catalog = catalog_path.is_file();
470 let fetched_at = format_utc_timestamp_secs(request.now_unix_secs);
471 let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
472 fetch_request.endpoint.clone_from(&request.source_endpoint);
473 let catalog = source.fetch_catalog(&fetch_request)?;
474 if catalog.network != request.cache.network {
475 return Err(SubnetCatalogHostError::RefreshNetworkMismatch {
476 requested: request.cache.network.clone(),
477 actual: catalog.network,
478 });
479 }
480 catalog.validate()?;
481 let catalog_json = catalog_to_pretty_json(&catalog)?;
482 if let Some(output_path) = &request.output_path {
483 write_text_output(output_path, &catalog_json).map_err(subnet_cache_error)?;
484 }
485 if !request.dry_run {
486 write_text_atomically(&catalog_path, &catalog_json).map_err(subnet_cache_error)?;
487 }
488 lock.release().map_err(subnet_cache_error)?;
489 Ok(SubnetCatalogRefreshReport {
490 schema_version: SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION,
491 network: catalog.network,
492 catalog_path: catalog_path.display().to_string(),
493 refresh_lock_path: lock_path.display().to_string(),
494 output_path: request
495 .output_path
496 .as_ref()
497 .map(|path| path.display().to_string()),
498 registry_canister_id: catalog.registry_canister_id,
499 registry_version: catalog.registry_version,
500 fetched_at: catalog.fetched_at,
501 source_endpoint: catalog.source_endpoint,
502 fetched_by: catalog.fetched_by,
503 dry_run: request.dry_run,
504 wrote_catalog: !request.dry_run,
505 replaced_existing_catalog,
506 subnet_count: catalog.subnets.len(),
507 routing_range_count: catalog.routing_ranges.len(),
508 })
509}
510
511pub(crate) fn build_subnet_catalog_list_report(
512 request: &SubnetCatalogListRequest,
513) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
514 build_subnet_catalog_list_report_with_source(request, &LiveNnsRegistryRefreshSource)
515}
516
517fn build_subnet_catalog_list_report_with_source(
518 request: &SubnetCatalogListRequest,
519 source: &dyn SubnetCatalogRefreshSource,
520) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
521 let cached = load_or_refresh_subnet_catalog(
522 &request.cache,
523 &request.source_endpoint,
524 request.now_unix_secs,
525 source,
526 )?;
527 let stale = catalog_stale_status(
528 &cached.catalog,
529 request.now_unix_secs,
530 request.stale_after_seconds,
531 );
532 let subnets = cached
533 .catalog
534 .subnets
535 .iter()
536 .filter(|subnet| subnet_matches_filters(subnet, request.filters))
537 .map(|subnet| subnet_row(&cached.catalog, subnet, request))
538 .collect::<Vec<_>>();
539
540 Ok(SubnetCatalogListReport {
541 schema_version: SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION,
542 network: cached.catalog.network,
543 catalog_path: cached.path.display().to_string(),
544 catalog_schema_version: cached.catalog.catalog_schema_version,
545 registry_canister_id: cached.catalog.registry_canister_id,
546 registry_version: cached.catalog.registry_version,
547 fetched_at: cached.catalog.fetched_at,
548 catalog_stale: stale.catalog_stale,
549 stale_reason: stale.stale_reason,
550 resolver_backend: cached.catalog.resolver_backend,
551 subnets,
552 })
553}
554
555pub(crate) fn build_subnet_catalog_info_report(
556 request: &SubnetCatalogInfoRequest,
557) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
558 build_subnet_catalog_info_report_with_source(request, &LiveNnsRegistryRefreshSource)
559}
560
561fn build_subnet_catalog_info_report_with_source(
562 request: &SubnetCatalogInfoRequest,
563 source: &dyn SubnetCatalogRefreshSource,
564) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
565 let cached = load_or_refresh_subnet_catalog(
566 &request.cache,
567 &request.source_endpoint,
568 request.now_unix_secs,
569 source,
570 )?;
571 let stale = catalog_stale_status(
572 &cached.catalog,
573 request.now_unix_secs,
574 request.stale_after_seconds,
575 );
576 let resolved = cached
577 .catalog
578 .resolve_principal_or_prefix(&request.input, request.forced)?;
579 let (charges_apply_to_subject, charge_applicability_reason) =
580 charge_applicability(resolved.resolved_as, resolved.subnet.subnet_kind);
581 let cycles_per_billion_instructions = catalog_cycles_per_billion(&resolved.subnet);
582 let rate_source = cycles_per_billion_instructions
583 .is_some()
584 .then(|| "nns-registry-cache".to_string());
585 let formula_version = cycles_per_billion_instructions
586 .is_some()
587 .then(|| FORMULA_VERSION.to_string());
588
589 Ok(SubnetCatalogInfoReport {
590 schema_version: SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION,
591 input_principal: resolved.input_principal,
592 resolved_as: resolved.resolved_as.as_str().to_string(),
593 resolved_from: resolved.resolved_from,
594 subnet_principal: resolved.subnet.subnet_principal,
595 subnet_kind: resolved.subnet.subnet_kind,
596 subnet_kind_source: resolved.subnet.subnet_kind_source,
597 subnet_specialization: resolved.subnet.subnet_specialization,
598 subnet_specialization_source: resolved.subnet.subnet_specialization_source,
599 geographic_scope: resolved.subnet.geographic_scope,
600 geographic_scope_source: resolved.subnet.geographic_scope_source,
601 subnet_label: resolved.subnet.subnet_label,
602 subnet_label_source: resolved.subnet.subnet_label_source,
603 node_count: resolved.subnet.node_count,
604 charges_apply_to_subject,
605 charge_applicability_reason,
606 registry_canister_id: cached.catalog.registry_canister_id,
607 registry_version: cached.catalog.registry_version,
608 catalog_schema_version: cached.catalog.catalog_schema_version,
609 catalog_path: cached.path.display().to_string(),
610 fetched_at: cached.catalog.fetched_at,
611 catalog_stale: stale.catalog_stale,
612 stale_reason: stale.stale_reason,
613 resolver_backend: cached.catalog.resolver_backend,
614 matched_canister_principal: resolved.matched_canister_principal,
615 matched_routing_range: resolved.matched_routing_range,
616 cycles_per_billion_instructions,
617 rate_source,
618 formula_version,
619 })
620}
621
622fn load_or_refresh_subnet_catalog(
623 request: &SubnetCatalogCacheRequest,
624 source_endpoint: &str,
625 now_unix_secs: u64,
626 source: &dyn SubnetCatalogRefreshSource,
627) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
628 match load_cached_subnet_catalog(request) {
629 Ok(cached) => Ok(cached),
630 Err(SubnetCatalogHostError::MissingCatalog { path }) => {
631 announce_cache_refresh("subnet catalog", &path, source_endpoint);
632 let refresh_request = SubnetCatalogRefreshRequest {
633 cache: request.clone(),
634 source_endpoint: source_endpoint.to_string(),
635 now_unix_secs,
636 lock_stale_after_seconds: DEFAULT_REFRESH_LOCK_STALE_SECONDS,
637 dry_run: false,
638 output_path: None,
639 };
640 refresh_subnet_catalog_with_source(&refresh_request, source)?;
641 load_cached_subnet_catalog(request)
642 }
643 Err(err) => Err(err),
644 }
645}
646
647#[must_use]
648pub(crate) fn catalog_stale_status(
649 catalog: &SubnetCatalog,
650 now_unix_secs: u64,
651 stale_after_seconds: u64,
652) -> CatalogStaleStatus {
653 let Some(fetched_at_unix_secs) = parse_utc_timestamp_secs(&catalog.fetched_at) else {
654 return CatalogStaleStatus {
655 catalog_stale: true,
656 stale_reason: "fetched_at_unparseable".to_string(),
657 stale_after_seconds,
658 fetched_at_unix_secs: None,
659 age_seconds: None,
660 };
661 };
662 let Some(age_seconds) = now_unix_secs.checked_sub(fetched_at_unix_secs) else {
663 return CatalogStaleStatus {
664 catalog_stale: false,
665 stale_reason: "fetched_at_in_future".to_string(),
666 stale_after_seconds,
667 fetched_at_unix_secs: Some(fetched_at_unix_secs),
668 age_seconds: None,
669 };
670 };
671 let catalog_stale = age_seconds > stale_after_seconds;
672 CatalogStaleStatus {
673 catalog_stale,
674 stale_reason: if catalog_stale { "expired" } else { "fresh" }.to_string(),
675 stale_after_seconds,
676 fetched_at_unix_secs: Some(fetched_at_unix_secs),
677 age_seconds: Some(age_seconds),
678 }
679}
680
681#[cfg(test)]
682#[cfg(test)]
683pub(crate) fn parse_stale_after_duration(value: &str) -> Result<u64, SubnetCatalogHostError> {
684 parse_duration_seconds(value).map_err(|_| SubnetCatalogHostError::InvalidStaleDuration {
685 value: value.to_string(),
686 })
687}
688
689fn subnet_cache_error(err: CacheFileError) -> SubnetCatalogHostError {
690 match err {
691 CacheFileError::CreateDirectory { path, source } => {
692 SubnetCatalogHostError::CreateCatalogDirectory { path, source }
693 }
694 CacheFileError::CreateRefreshLock { path, source } => {
695 SubnetCatalogHostError::CreateRefreshLock { path, source }
696 }
697 CacheFileError::ReadRefreshLock { path, source } => {
698 SubnetCatalogHostError::ReadRefreshLock { path, source }
699 }
700 CacheFileError::ParseRefreshLock { path, source } => {
701 SubnetCatalogHostError::ParseRefreshLock { path, source }
702 }
703 CacheFileError::WriteRefreshLock { path, source } => {
704 SubnetCatalogHostError::WriteRefreshLock { path, source }
705 }
706 CacheFileError::RemoveRefreshLock { path, source } => {
707 SubnetCatalogHostError::RemoveRefreshLock { path, source }
708 }
709 CacheFileError::RefreshAlreadyInProgress {
710 path,
711 started_at_unix_ms,
712 } => SubnetCatalogHostError::RefreshAlreadyInProgress {
713 path,
714 started_at_unix_ms,
715 },
716 CacheFileError::WriteTemp { path, source } => {
717 SubnetCatalogHostError::WriteCatalogTemp { path, source }
718 }
719 CacheFileError::SyncTemp { path, source } => {
720 SubnetCatalogHostError::SyncCatalogTemp { path, source }
721 }
722 CacheFileError::Replace {
723 temp_path,
724 target_path,
725 source,
726 } => SubnetCatalogHostError::ReplaceCatalog {
727 temp_path,
728 catalog_path: target_path,
729 source,
730 },
731 CacheFileError::SyncDirectory { path, source } => {
732 SubnetCatalogHostError::SyncCatalogDirectory { path, source }
733 }
734 CacheFileError::WriteOutput { path, source } => {
735 SubnetCatalogHostError::WriteRefreshOutput { path, source }
736 }
737 CacheFileError::SyncOutput { path, source } => {
738 SubnetCatalogHostError::SyncRefreshOutput { path, source }
739 }
740 }
741}
742
743#[must_use]
744pub(crate) fn subnet_catalog_list_report_text(report: &SubnetCatalogListReport) -> String {
745 let headers = [
746 "SUBNET", "KIND", "SPEC", "GEO", "NODES", "CHG", "RANGES", "STALE",
747 ];
748 let rows = report
749 .subnets
750 .iter()
751 .map(|subnet| {
752 [
753 compact_principal(&subnet.subnet_principal),
754 subnet.subnet_kind.as_str().to_string(),
755 subnet.subnet_specialization.as_str().to_string(),
756 subnet.geographic_scope.as_str().to_string(),
757 subnet
758 .node_count
759 .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
760 yes_no(subnet.charges_apply_by_default).to_string(),
761 subnet.range_count.to_string(),
762 yes_no(report.catalog_stale).to_string(),
763 ]
764 })
765 .collect::<Vec<_>>();
766 let alignments = [
767 ColumnAlign::Left,
768 ColumnAlign::Left,
769 ColumnAlign::Left,
770 ColumnAlign::Left,
771 ColumnAlign::Right,
772 ColumnAlign::Left,
773 ColumnAlign::Right,
774 ColumnAlign::Left,
775 ];
776 let mut lines = Vec::new();
777 lines.push(format!(
778 "catalog: {} version {} stale {}",
779 report.network,
780 report.registry_version,
781 yes_no(report.catalog_stale)
782 ));
783 if rows.is_empty() {
784 lines.push("subnets: none".to_string());
785 return lines.join("\n");
786 }
787 lines.push(render_table(&headers, &rows, &alignments));
788 append_compact_range_lines(report, &mut lines);
789 lines.join("\n")
790}
791
792#[must_use]
793pub(crate) fn subnet_catalog_list_report_verbose_text(report: &SubnetCatalogListReport) -> String {
794 let headers = [
795 "SUBNET",
796 "KIND",
797 "SPECIALIZATION",
798 "GEO",
799 "NODES",
800 "CHARGES",
801 "RANGES",
802 "VERSION",
803 "FETCHED_AT",
804 "STALE",
805 ];
806 let rows = report
807 .subnets
808 .iter()
809 .map(|subnet| {
810 [
811 subnet.subnet_principal.clone(),
812 subnet.subnet_kind.as_str().to_string(),
813 subnet.subnet_specialization.as_str().to_string(),
814 subnet.geographic_scope.as_str().to_string(),
815 subnet
816 .node_count
817 .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
818 yes_no(subnet.charges_apply_by_default).to_string(),
819 subnet.range_count.to_string(),
820 report.registry_version.to_string(),
821 report.fetched_at.clone(),
822 yes_no(report.catalog_stale).to_string(),
823 ]
824 })
825 .collect::<Vec<_>>();
826 let alignments = [
827 ColumnAlign::Left,
828 ColumnAlign::Left,
829 ColumnAlign::Left,
830 ColumnAlign::Left,
831 ColumnAlign::Right,
832 ColumnAlign::Left,
833 ColumnAlign::Right,
834 ColumnAlign::Right,
835 ColumnAlign::Left,
836 ColumnAlign::Left,
837 ];
838 let mut lines = Vec::new();
839 lines.push(format!("catalog_path: {}", report.catalog_path));
840 lines.push(format!("stale_reason: {}", report.stale_reason));
841 if rows.is_empty() {
842 lines.push("subnets: none".to_string());
843 return lines.join("\n");
844 }
845 lines.push(render_table(&headers, &rows, &alignments));
846 append_range_lines(report, &mut lines);
847 lines.join("\n")
848}
849
850#[must_use]
851pub(crate) fn subnet_catalog_info_report_text(report: &SubnetCatalogInfoReport) -> String {
852 let mut lines = Vec::new();
853 lines.push(format!("input_principal: {}", report.input_principal));
854 lines.push(format!("resolved_as: {}", report.resolved_as));
855 lines.push(format!("resolved_from: {}", report.resolved_from));
856 lines.push(format!("subnet_principal: {}", report.subnet_principal));
857 lines.push(format!("subnet_kind: {}", report.subnet_kind.as_str()));
858 lines.push(format!(
859 "subnet_kind_source: {}",
860 report.subnet_kind_source.as_str()
861 ));
862 lines.push(format!(
863 "subnet_specialization: {}",
864 report.subnet_specialization.as_str()
865 ));
866 lines.push(format!(
867 "subnet_specialization_source: {}",
868 report.subnet_specialization_source.as_str()
869 ));
870 lines.push(format!(
871 "geographic_scope: {}",
872 report.geographic_scope.as_str()
873 ));
874 lines.push(format!(
875 "geographic_scope_source: {}",
876 report.geographic_scope_source.as_str()
877 ));
878 lines.push(format!("subnet_label: {}", report.subnet_label));
879 lines.push(format!(
880 "subnet_label_source: {}",
881 report.subnet_label_source.as_str()
882 ));
883 lines.push(format!(
884 "node_count: {}",
885 report
886 .node_count
887 .map_or_else(|| "unknown".to_string(), |count| count.to_string())
888 ));
889 lines.push(format!(
890 "charges_apply_to_subject: {}",
891 yes_no(report.charges_apply_to_subject)
892 ));
893 lines.push(format!(
894 "charge_applicability_reason: {}",
895 report.charge_applicability_reason
896 ));
897 lines.push(format!(
898 "registry_canister_id: {}",
899 report.registry_canister_id
900 ));
901 lines.push(format!("registry_version: {}", report.registry_version));
902 lines.push(format!(
903 "catalog_schema_version: {}",
904 report.catalog_schema_version
905 ));
906 lines.push(format!("catalog_path: {}", report.catalog_path));
907 lines.push(format!("fetched_at: {}", report.fetched_at));
908 lines.push(format!("catalog_stale: {}", yes_no(report.catalog_stale)));
909 lines.push(format!("stale_reason: {}", report.stale_reason));
910 lines.push(format!("resolver_backend: {}", report.resolver_backend));
911 if let Some(canister) = &report.matched_canister_principal {
912 lines.push(format!("matched_canister_principal: {canister}"));
913 }
914 if let Some(range) = &report.matched_routing_range {
915 lines.push(format!(
916 "matched_routing_range: {}..{}",
917 range.start_canister_id, range.end_canister_id
918 ));
919 }
920 lines.push(format!(
921 "cycles_per_billion_instructions: {}",
922 report
923 .cycles_per_billion_instructions
924 .map_or_else(|| "not_applicable".to_string(), |cycles| cycles.to_string())
925 ));
926 if let Some(rate_source) = &report.rate_source {
927 lines.push(format!("rate_source: {rate_source}"));
928 }
929 if let Some(formula_version) = &report.formula_version {
930 lines.push(format!("formula_version: {formula_version}"));
931 }
932 lines.join("\n")
933}
934
935#[must_use]
936pub(crate) fn subnet_catalog_refresh_report_text(report: &SubnetCatalogRefreshReport) -> String {
937 [
938 format!("network: {}", report.network),
939 format!("catalog_path: {}", report.catalog_path),
940 format!("refresh_lock_path: {}", report.refresh_lock_path),
941 format!("registry_canister_id: {}", report.registry_canister_id),
942 format!("registry_version: {}", report.registry_version),
943 format!("fetched_at: {}", report.fetched_at),
944 format!("source_endpoint: {}", report.source_endpoint),
945 format!("fetched_by: {}", report.fetched_by),
946 format!("dry_run: {}", yes_no(report.dry_run)),
947 format!("wrote_catalog: {}", yes_no(report.wrote_catalog)),
948 format!(
949 "replaced_existing_catalog: {}",
950 yes_no(report.replaced_existing_catalog)
951 ),
952 format!("subnet_count: {}", report.subnet_count),
953 format!("routing_range_count: {}", report.routing_range_count),
954 ]
955 .join("\n")
956}
957
958fn enforce_mainnet_network(network: &str) -> Result<(), SubnetCatalogHostError> {
959 if network == MAINNET_NETWORK {
960 return Ok(());
961 }
962 Err(SubnetCatalogHostError::UnsupportedNetwork {
963 network: network.to_string(),
964 })
965}
966
967trait SubnetCatalogRefreshSource {
968 fn fetch_catalog(
969 &self,
970 request: &MainnetRegistryFetchRequest,
971 ) -> Result<SubnetCatalog, SubnetCatalogHostError>;
972}
973
974struct LiveNnsRegistryRefreshSource;
978
979impl SubnetCatalogRefreshSource for LiveNnsRegistryRefreshSource {
980 fn fetch_catalog(
981 &self,
982 request: &MainnetRegistryFetchRequest,
983 ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
984 Ok(fetch_mainnet_subnet_catalog(request)?)
985 }
986}
987
988fn subnet_matches_filters(subnet: &SubnetInfo, filters: SubnetCatalogFilters) -> bool {
989 filters.kind.is_none_or(|kind| subnet.subnet_kind == kind)
990 && filters
991 .specialization
992 .is_none_or(|specialization| subnet.subnet_specialization == specialization)
993 && filters
994 .geographic_scope
995 .is_none_or(|scope| subnet.geographic_scope == scope)
996}
997
998fn subnet_row(
999 catalog: &SubnetCatalog,
1000 subnet: &SubnetInfo,
1001 request: &SubnetCatalogListRequest,
1002) -> SubnetCatalogSubnetRow {
1003 let ranges = catalog.routing_ranges_for_subnet(&subnet.subnet_principal);
1004 let range_count = ranges.len();
1005 let shown_ranges = if request.show_ranges {
1006 ranges
1007 .into_iter()
1008 .skip(request.range_offset)
1009 .take(request.range_limit)
1010 .cloned()
1011 .collect::<Vec<_>>()
1012 } else {
1013 Vec::new()
1014 };
1015 SubnetCatalogSubnetRow {
1016 subnet_principal: subnet.subnet_principal.clone(),
1017 subnet_kind: subnet.subnet_kind,
1018 subnet_kind_source: subnet.subnet_kind_source,
1019 subnet_specialization: subnet.subnet_specialization,
1020 subnet_specialization_source: subnet.subnet_specialization_source,
1021 geographic_scope: subnet.geographic_scope,
1022 geographic_scope_source: subnet.geographic_scope_source,
1023 subnet_label: subnet.subnet_label.clone(),
1024 subnet_label_source: subnet.subnet_label_source,
1025 node_count: subnet.node_count,
1026 charges_apply_by_default: subnet.charges_apply_by_default,
1027 range_count,
1028 ranges_shown: shown_ranges.len(),
1029 range_offset: request.range_offset,
1030 range_limit: request.range_limit,
1031 ranges: shown_ranges,
1032 }
1033}
1034
1035fn charge_applicability(subject: ResolvedSubnetSubject, kind: SubnetKind) -> (bool, String) {
1036 match kind {
1037 SubnetKind::Application | SubnetKind::CloudEngine => {
1038 (true, "charged_user_canister_subnet".to_string())
1039 }
1040 SubnetKind::System if subject == ResolvedSubnetSubject::Subnet => {
1041 (false, "system_subnet_core_canister".to_string())
1042 }
1043 SubnetKind::System => (false, "system_subnet_unknown_subject".to_string()),
1044 SubnetKind::Unknown => (false, "unknown_subnet_type".to_string()),
1045 }
1046}
1047
1048fn catalog_cycles_per_billion(subnet: &SubnetInfo) -> Option<u128> {
1049 if !subnet.subnet_kind.charges_apply_by_default() {
1050 return None;
1051 }
1052 let node_count = u128::from(subnet.node_count?);
1053 if node_count == 0 {
1054 return None;
1055 }
1056 Some(ceil_div(
1057 BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS * node_count,
1058 13,
1059 ))
1060}
1061
1062const fn ceil_div(numerator: u128, denominator: u128) -> u128 {
1063 numerator.div_ceil(denominator)
1064}
1065
1066fn append_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
1067 for subnet in &report.subnets {
1068 if subnet.ranges.is_empty() {
1069 continue;
1070 }
1071 lines.push(format!("ranges for {}:", subnet.subnet_principal));
1072 for range in &subnet.ranges {
1073 lines.push(format!(
1074 " {}..{}",
1075 range.start_canister_id, range.end_canister_id
1076 ));
1077 }
1078 if subnet.ranges_shown < subnet.range_count {
1079 lines.push(format!(
1080 " showing {} of {} ranges; use --range-limit or --format json",
1081 subnet.ranges_shown, subnet.range_count
1082 ));
1083 }
1084 }
1085}
1086
1087fn append_compact_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
1088 for subnet in &report.subnets {
1089 if subnet.ranges.is_empty() {
1090 continue;
1091 }
1092 lines.push(format!(
1093 "ranges for {}:",
1094 compact_principal(&subnet.subnet_principal)
1095 ));
1096 for range in &subnet.ranges {
1097 lines.push(format!(
1098 " {}..{}",
1099 compact_principal(&range.start_canister_id),
1100 compact_principal(&range.end_canister_id)
1101 ));
1102 }
1103 if subnet.ranges_shown < subnet.range_count {
1104 lines.push(format!(
1105 " showing {} of {} ranges; use --range-limit or --format json",
1106 subnet.ranges_shown, subnet.range_count
1107 ));
1108 }
1109 }
1110}
1111
1112fn compact_principal(value: &str) -> String {
1113 value.chars().take(5).collect()
1114}
1115
1116fn parse_utc_timestamp_secs(value: &str) -> Option<u64> {
1117 let value = value.strip_suffix('Z')?;
1118 let (date, time) = value.split_once('T')?;
1119 let mut date_parts = date.split('-');
1120 let year = date_parts.next()?.parse::<i64>().ok()?;
1121 let month = date_parts.next()?.parse::<u32>().ok()?;
1122 let day = date_parts.next()?.parse::<u32>().ok()?;
1123 if date_parts.next().is_some() {
1124 return None;
1125 }
1126 let mut time_parts = time.split(':');
1127 let hour = time_parts.next()?.parse::<u32>().ok()?;
1128 let minute = time_parts.next()?.parse::<u32>().ok()?;
1129 let second = time_parts.next()?.parse::<u32>().ok()?;
1130 if time_parts.next().is_some()
1131 || !(1..=12).contains(&month)
1132 || !(1..=31).contains(&day)
1133 || hour > 23
1134 || minute > 59
1135 || second > 59
1136 {
1137 return None;
1138 }
1139 let days = days_from_civil(year, month, day)?;
1140 let seconds = days
1141 .checked_mul(86_400)?
1142 .checked_add(i64::from(hour) * 3_600)?
1143 .checked_add(i64::from(minute) * 60)?
1144 .checked_add(i64::from(second))?;
1145 u64::try_from(seconds).ok()
1146}
1147
1148pub(crate) fn format_utc_timestamp_secs(value: u64) -> String {
1149 let days = i64::try_from(value / 86_400).unwrap_or(i64::MAX);
1150 let seconds_of_day = value % 86_400;
1151 let (year, month, day) = civil_from_days(days);
1152 let hour = seconds_of_day / 3_600;
1153 let minute = (seconds_of_day % 3_600) / 60;
1154 let second = seconds_of_day % 60;
1155 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1156}
1157
1158fn civil_from_days(days: i64) -> (i64, u32, u32) {
1159 let days = days + 719_468;
1160 let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1161 let day_of_era = days - era * 146_097;
1162 let year_of_era =
1163 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1164 let mut year = year_of_era + era * 400;
1165 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1166 let month_prime = (5 * day_of_year + 2) / 153;
1167 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1168 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1169 year += i64::from(month <= 2);
1170 (
1171 year,
1172 u32::try_from(month).expect("civil month is in u32 range"),
1173 u32::try_from(day).expect("civil day is in u32 range"),
1174 )
1175}
1176
1177fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1178 let month = i64::from(month);
1179 let day = i64::from(day);
1180 let year = year - i64::from(month <= 2);
1181 let era = if year >= 0 { year } else { year - 399 } / 400;
1182 let year_of_era = year - era * 400;
1183 let month_prime = month + if month > 2 { -3 } else { 9 };
1184 let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
1185 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1186 era.checked_mul(146_097)?
1187 .checked_add(day_of_era)?
1188 .checked_sub(719_468)
1189}
1190
1191#[cfg(test)]
1192mod core_tests;
1193#[cfg(test)]
1194mod tests;