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