1use crate::{
2 cache_file::{
3 CacheFileError, RefreshLockRequest, acquire_refresh_lock, create_directory,
4 write_text_atomically, write_text_output,
5 },
6 duration::parse_duration_seconds,
7 table::{ColumnAlign, render_table},
8};
9use canic_ic_registry::{
10 DEFAULT_MAINNET_ENDPOINT, MainnetRegistryFetchRequest, RegistryFetchError,
11 fetch_mainnet_subnet_catalog,
12};
13use canic_subnet_catalog::{
14 CatalogError, ClassificationSource, GeographicScope, MAINNET_NETWORK, ResolveAs,
15 ResolvedSubnetSubject, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
16 SubnetSpecialization, catalog_to_pretty_json, parse_catalog_json,
17};
18use serde::{Deserialize, Serialize};
19use std::{
20 fs, io,
21 path::{Path, PathBuf},
22};
23use thiserror::Error as ThisError;
24
25pub const DEFAULT_STALE_AFTER_SECONDS: u64 = 7 * 24 * 60 * 60;
26pub const DEFAULT_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
27pub const DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
28pub const SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
29pub const SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
30pub const SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
31const BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS: u128 = 1_000_000_000;
32const FORMULA_VERSION: &str = "base_13_node_linear_v1";
33
34#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct SubnetCatalogCacheRequest {
39 pub icp_root: PathBuf,
40 pub network: String,
41}
42
43#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct CachedSubnetCatalog {
48 pub path: PathBuf,
49 pub catalog: SubnetCatalog,
50}
51
52#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct SubnetCatalogFilters {
57 pub kind: Option<SubnetKind>,
58 pub specialization: Option<SubnetSpecialization>,
59 pub geographic_scope: Option<GeographicScope>,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct SubnetCatalogListRequest {
67 pub cache: SubnetCatalogCacheRequest,
68 pub now_unix_secs: u64,
69 pub stale_after_seconds: u64,
70 pub filters: SubnetCatalogFilters,
71 pub show_ranges: bool,
72 pub range_limit: usize,
73 pub range_offset: usize,
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct SubnetCatalogInfoRequest {
81 pub cache: SubnetCatalogCacheRequest,
82 pub input: String,
83 pub forced: Option<ResolveAs>,
84 pub resolved_target: Option<ResolvedDeploymentTarget>,
85 pub now_unix_secs: u64,
86 pub stale_after_seconds: u64,
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct SubnetCatalogRefreshRequest {
94 pub cache: SubnetCatalogCacheRequest,
95 pub source_endpoint: String,
96 pub now_unix_secs: u64,
97 pub lock_stale_after_seconds: u64,
98 pub dry_run: bool,
99 pub output_path: Option<PathBuf>,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct ResolvedDeploymentTarget {
107 pub canister_principal: String,
108 pub resolved_from: String,
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115pub struct CatalogStaleStatus {
116 pub catalog_stale: bool,
117 pub stale_reason: String,
118 pub stale_after_seconds: u64,
119 pub fetched_at_unix_secs: Option<u64>,
120 pub age_seconds: Option<u64>,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct SubnetCatalogListReport {
128 pub schema_version: u32,
129 pub network: String,
130 pub catalog_path: String,
131 pub catalog_schema_version: u32,
132 pub registry_canister_id: String,
133 pub registry_version: u64,
134 pub fetched_at: String,
135 pub catalog_stale: bool,
136 pub stale_reason: String,
137 pub resolver_backend: String,
138 pub subnets: Vec<SubnetCatalogSubnetRow>,
139}
140
141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
145pub struct SubnetCatalogSubnetRow {
146 pub subnet_principal: String,
147 pub subnet_kind: SubnetKind,
148 pub subnet_kind_source: ClassificationSource,
149 pub subnet_specialization: SubnetSpecialization,
150 pub subnet_specialization_source: ClassificationSource,
151 pub geographic_scope: GeographicScope,
152 pub geographic_scope_source: ClassificationSource,
153 pub subnet_label: String,
154 pub subnet_label_source: ClassificationSource,
155 pub node_count: Option<u32>,
156 pub charges_apply_by_default: bool,
157 pub range_count: usize,
158 pub ranges_shown: usize,
159 pub range_offset: usize,
160 pub range_limit: usize,
161 pub ranges: Vec<RoutingRange>,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168pub struct SubnetCatalogInfoReport {
169 pub schema_version: u32,
170 pub input_principal: String,
171 pub resolved_as: String,
172 pub resolved_from: String,
173 pub subnet_principal: String,
174 pub subnet_kind: SubnetKind,
175 pub subnet_kind_source: ClassificationSource,
176 pub subnet_specialization: SubnetSpecialization,
177 pub subnet_specialization_source: ClassificationSource,
178 pub geographic_scope: GeographicScope,
179 pub geographic_scope_source: ClassificationSource,
180 pub subnet_label: String,
181 pub subnet_label_source: ClassificationSource,
182 pub node_count: Option<u32>,
183 pub charges_apply_to_subject: bool,
184 pub charge_applicability_reason: String,
185 pub registry_canister_id: String,
186 pub registry_version: u64,
187 pub catalog_schema_version: u32,
188 pub catalog_path: String,
189 pub fetched_at: String,
190 pub catalog_stale: bool,
191 pub stale_reason: String,
192 pub resolver_backend: String,
193 pub matched_canister_principal: Option<String>,
194 pub matched_routing_range: Option<RoutingRange>,
195 pub cycles_per_billion_instructions: Option<u128>,
196 pub rate_source: Option<String>,
197 pub formula_version: Option<String>,
198}
199
200#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204pub struct SubnetCatalogRefreshReport {
205 pub schema_version: u32,
206 pub network: String,
207 pub catalog_path: String,
208 pub refresh_lock_path: String,
209 pub output_path: Option<String>,
210 pub registry_canister_id: String,
211 pub registry_version: u64,
212 pub fetched_at: String,
213 pub source_endpoint: String,
214 pub fetched_by: String,
215 pub dry_run: bool,
216 pub wrote_catalog: bool,
217 pub replaced_existing_catalog: bool,
218 pub subnet_count: usize,
219 pub routing_range_count: usize,
220}
221
222#[derive(Debug, ThisError)]
226pub enum SubnetCatalogHostError {
227 #[error(
228 "`canic 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 canic --network ic nns subnet list"
229 )]
230 UnsupportedNetwork { network: String },
231
232 #[error(
233 "subnet catalog cache is missing at {}\n\nRun `canic nns subnet refresh` to fetch the public Internet Computer mainnet catalog, or populate this path with a valid Canic subnet catalog JSON.",
234 path.display()
235 )]
236 MissingCatalog { path: PathBuf },
237
238 #[error("failed to read subnet catalog at {}: {source}", path.display())]
239 ReadCatalog { path: PathBuf, source: io::Error },
240
241 #[error(
242 "cached subnet catalog network mismatch: path is for {requested}, catalog is for {actual}"
243 )]
244 NetworkMismatch { requested: String, actual: String },
245
246 #[error(
247 "invalid stale duration {value:?}; use positive seconds or a value ending in s, m, h, or d"
248 )]
249 InvalidStaleDuration { value: String },
250
251 #[error("subnet catalog refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
252 RefreshAlreadyInProgress {
253 path: PathBuf,
254 started_at_unix_ms: u64,
255 },
256
257 #[error("failed to create subnet catalog directory at {}: {source}", path.display())]
258 CreateCatalogDirectory { path: PathBuf, source: io::Error },
259
260 #[error("failed to create refresh lock at {}: {source}", path.display())]
261 CreateRefreshLock { path: PathBuf, source: io::Error },
262
263 #[error("failed to read refresh lock at {}: {source}", path.display())]
264 ReadRefreshLock { path: PathBuf, source: io::Error },
265
266 #[error("failed to parse refresh lock at {}: {source}", path.display())]
267 ParseRefreshLock {
268 path: PathBuf,
269 source: serde_json::Error,
270 },
271
272 #[error("failed to write refresh lock at {}: {source}", path.display())]
273 WriteRefreshLock { path: PathBuf, source: io::Error },
274
275 #[error("failed to remove refresh lock at {}: {source}", path.display())]
276 RemoveRefreshLock { path: PathBuf, source: io::Error },
277
278 #[error("live NNS registry refresh failed: {0}")]
279 RegistryRefresh(#[from] RegistryFetchError),
280
281 #[error("refreshed subnet catalog network mismatch: requested {requested}, fetched {actual}")]
282 RefreshNetworkMismatch { requested: String, actual: String },
283
284 #[error("failed to write subnet catalog temp file at {}: {source}", path.display())]
285 WriteCatalogTemp { path: PathBuf, source: io::Error },
286
287 #[error("failed to sync subnet catalog temp file at {}: {source}", path.display())]
288 SyncCatalogTemp { path: PathBuf, source: io::Error },
289
290 #[error("failed to replace subnet catalog at {} from {}: {source}", catalog_path.display(), temp_path.display())]
291 ReplaceCatalog {
292 temp_path: PathBuf,
293 catalog_path: PathBuf,
294 source: io::Error,
295 },
296
297 #[error("failed to sync subnet catalog directory at {}: {source}", path.display())]
298 SyncCatalogDirectory { path: PathBuf, source: io::Error },
299
300 #[error("failed to write refreshed subnet catalog output at {}: {source}", path.display())]
301 WriteRefreshOutput { path: PathBuf, source: io::Error },
302
303 #[error("failed to sync refreshed subnet catalog output at {}: {source}", path.display())]
304 SyncRefreshOutput { path: PathBuf, source: io::Error },
305
306 #[error(transparent)]
307 Catalog(#[from] CatalogError),
308}
309
310#[must_use]
311pub fn subnet_catalog_path(icp_root: &Path, network: &str) -> PathBuf {
312 icp_root
313 .join(".canic")
314 .join("subnet-catalog")
315 .join(network)
316 .join("catalog.json")
317}
318
319#[must_use]
320pub fn subnet_catalog_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
321 icp_root
322 .join(".canic")
323 .join("subnet-catalog")
324 .join(network)
325 .join("refresh.lock")
326}
327
328pub fn load_cached_subnet_catalog(
329 request: &SubnetCatalogCacheRequest,
330) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
331 enforce_mainnet_network(&request.network)?;
332 let path = subnet_catalog_path(&request.icp_root, &request.network);
333 if !path.is_file() {
334 return Err(SubnetCatalogHostError::MissingCatalog { path });
335 }
336 let data = fs::read_to_string(&path).map_err(|source| SubnetCatalogHostError::ReadCatalog {
337 path: path.clone(),
338 source,
339 })?;
340 let catalog = parse_catalog_json(&data)?;
341 if catalog.network != request.network {
342 return Err(SubnetCatalogHostError::NetworkMismatch {
343 requested: request.network.clone(),
344 actual: catalog.network,
345 });
346 }
347 Ok(CachedSubnetCatalog { path, catalog })
348}
349
350pub fn refresh_subnet_catalog(
351 request: &SubnetCatalogRefreshRequest,
352) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
353 refresh_subnet_catalog_with_source(request, &LiveNnsRegistryRefreshSource)
354}
355
356fn refresh_subnet_catalog_with_source(
357 request: &SubnetCatalogRefreshRequest,
358 source: &dyn SubnetCatalogRefreshSource,
359) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
360 enforce_mainnet_network(&request.cache.network)?;
361 let catalog_path = subnet_catalog_path(&request.cache.icp_root, &request.cache.network);
362 let lock_path =
363 subnet_catalog_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
364 let catalog_dir = catalog_path
365 .parent()
366 .expect("subnet catalog path always has parent")
367 .to_path_buf();
368 create_directory(&catalog_dir).map_err(subnet_cache_error)?;
369 let lock = acquire_refresh_lock(RefreshLockRequest {
370 lock_path: &lock_path,
371 target_path: &catalog_path,
372 network: &request.cache.network,
373 now_unix_secs: request.now_unix_secs,
374 lock_stale_after_seconds: request.lock_stale_after_seconds,
375 })
376 .map_err(subnet_cache_error)?;
377 let replaced_existing_catalog = catalog_path.is_file();
378 let fetched_at = format_utc_timestamp_secs(request.now_unix_secs);
379 let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
380 fetch_request.endpoint.clone_from(&request.source_endpoint);
381 let catalog = source.fetch_catalog(&fetch_request)?;
382 if catalog.network != request.cache.network {
383 return Err(SubnetCatalogHostError::RefreshNetworkMismatch {
384 requested: request.cache.network.clone(),
385 actual: catalog.network,
386 });
387 }
388 catalog.validate()?;
389 let catalog_json = catalog_to_pretty_json(&catalog)?;
390 if let Some(output_path) = &request.output_path {
391 write_text_output(output_path, &catalog_json).map_err(subnet_cache_error)?;
392 }
393 if !request.dry_run {
394 write_text_atomically(&catalog_path, &catalog_json).map_err(subnet_cache_error)?;
395 }
396 lock.release().map_err(subnet_cache_error)?;
397 Ok(SubnetCatalogRefreshReport {
398 schema_version: SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION,
399 network: catalog.network,
400 catalog_path: catalog_path.display().to_string(),
401 refresh_lock_path: lock_path.display().to_string(),
402 output_path: request
403 .output_path
404 .as_ref()
405 .map(|path| path.display().to_string()),
406 registry_canister_id: catalog.registry_canister_id,
407 registry_version: catalog.registry_version,
408 fetched_at: catalog.fetched_at,
409 source_endpoint: catalog.source_endpoint,
410 fetched_by: catalog.fetched_by,
411 dry_run: request.dry_run,
412 wrote_catalog: !request.dry_run,
413 replaced_existing_catalog,
414 subnet_count: catalog.subnets.len(),
415 routing_range_count: catalog.routing_ranges.len(),
416 })
417}
418
419pub fn build_subnet_catalog_list_report(
420 request: &SubnetCatalogListRequest,
421) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
422 let cached = load_cached_subnet_catalog(&request.cache)?;
423 let stale = catalog_stale_status(
424 &cached.catalog,
425 request.now_unix_secs,
426 request.stale_after_seconds,
427 );
428 let subnets = cached
429 .catalog
430 .subnets
431 .iter()
432 .filter(|subnet| subnet_matches_filters(subnet, request.filters))
433 .map(|subnet| subnet_row(&cached.catalog, subnet, request))
434 .collect::<Vec<_>>();
435
436 Ok(SubnetCatalogListReport {
437 schema_version: SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION,
438 network: cached.catalog.network,
439 catalog_path: cached.path.display().to_string(),
440 catalog_schema_version: cached.catalog.catalog_schema_version,
441 registry_canister_id: cached.catalog.registry_canister_id,
442 registry_version: cached.catalog.registry_version,
443 fetched_at: cached.catalog.fetched_at,
444 catalog_stale: stale.catalog_stale,
445 stale_reason: stale.stale_reason,
446 resolver_backend: cached.catalog.resolver_backend,
447 subnets,
448 })
449}
450
451pub fn build_subnet_catalog_info_report(
452 request: &SubnetCatalogInfoRequest,
453) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
454 let cached = load_cached_subnet_catalog(&request.cache)?;
455 let stale = catalog_stale_status(
456 &cached.catalog,
457 request.now_unix_secs,
458 request.stale_after_seconds,
459 );
460 let resolved = if let Some(target) = &request.resolved_target {
461 let mut resolved = cached
462 .catalog
463 .resolve_canister(&target.canister_principal)?;
464 resolved.input_principal.clone_from(&request.input);
465 resolved.resolved_from.clone_from(&target.resolved_from);
466 resolved
467 } else {
468 cached
469 .catalog
470 .resolve_principal_or_prefix(&request.input, request.forced)?
471 };
472 let (charges_apply_to_subject, charge_applicability_reason) =
473 charge_applicability(resolved.resolved_as, resolved.subnet.subnet_kind);
474 let cycles_per_billion_instructions = catalog_cycles_per_billion(&resolved.subnet);
475 let rate_source = cycles_per_billion_instructions
476 .is_some()
477 .then(|| "nns-registry-cache".to_string());
478 let formula_version = cycles_per_billion_instructions
479 .is_some()
480 .then(|| FORMULA_VERSION.to_string());
481
482 Ok(SubnetCatalogInfoReport {
483 schema_version: SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION,
484 input_principal: resolved.input_principal,
485 resolved_as: resolved.resolved_as.as_str().to_string(),
486 resolved_from: resolved.resolved_from,
487 subnet_principal: resolved.subnet.subnet_principal,
488 subnet_kind: resolved.subnet.subnet_kind,
489 subnet_kind_source: resolved.subnet.subnet_kind_source,
490 subnet_specialization: resolved.subnet.subnet_specialization,
491 subnet_specialization_source: resolved.subnet.subnet_specialization_source,
492 geographic_scope: resolved.subnet.geographic_scope,
493 geographic_scope_source: resolved.subnet.geographic_scope_source,
494 subnet_label: resolved.subnet.subnet_label,
495 subnet_label_source: resolved.subnet.subnet_label_source,
496 node_count: resolved.subnet.node_count,
497 charges_apply_to_subject,
498 charge_applicability_reason,
499 registry_canister_id: cached.catalog.registry_canister_id,
500 registry_version: cached.catalog.registry_version,
501 catalog_schema_version: cached.catalog.catalog_schema_version,
502 catalog_path: cached.path.display().to_string(),
503 fetched_at: cached.catalog.fetched_at,
504 catalog_stale: stale.catalog_stale,
505 stale_reason: stale.stale_reason,
506 resolver_backend: cached.catalog.resolver_backend,
507 matched_canister_principal: resolved.matched_canister_principal,
508 matched_routing_range: resolved.matched_routing_range,
509 cycles_per_billion_instructions,
510 rate_source,
511 formula_version,
512 })
513}
514
515#[must_use]
516pub fn catalog_stale_status(
517 catalog: &SubnetCatalog,
518 now_unix_secs: u64,
519 stale_after_seconds: u64,
520) -> CatalogStaleStatus {
521 let Some(fetched_at_unix_secs) = parse_utc_timestamp_secs(&catalog.fetched_at) else {
522 return CatalogStaleStatus {
523 catalog_stale: true,
524 stale_reason: "fetched_at_unparseable".to_string(),
525 stale_after_seconds,
526 fetched_at_unix_secs: None,
527 age_seconds: None,
528 };
529 };
530 let Some(age_seconds) = now_unix_secs.checked_sub(fetched_at_unix_secs) else {
531 return CatalogStaleStatus {
532 catalog_stale: false,
533 stale_reason: "fetched_at_in_future".to_string(),
534 stale_after_seconds,
535 fetched_at_unix_secs: Some(fetched_at_unix_secs),
536 age_seconds: None,
537 };
538 };
539 let catalog_stale = age_seconds > stale_after_seconds;
540 CatalogStaleStatus {
541 catalog_stale,
542 stale_reason: if catalog_stale { "expired" } else { "fresh" }.to_string(),
543 stale_after_seconds,
544 fetched_at_unix_secs: Some(fetched_at_unix_secs),
545 age_seconds: Some(age_seconds),
546 }
547}
548
549pub fn parse_stale_after_duration(value: &str) -> Result<u64, SubnetCatalogHostError> {
550 parse_duration_seconds(value).map_err(|_| SubnetCatalogHostError::InvalidStaleDuration {
551 value: value.to_string(),
552 })
553}
554
555fn subnet_cache_error(err: CacheFileError) -> SubnetCatalogHostError {
556 match err {
557 CacheFileError::CreateDirectory { path, source } => {
558 SubnetCatalogHostError::CreateCatalogDirectory { path, source }
559 }
560 CacheFileError::CreateRefreshLock { path, source } => {
561 SubnetCatalogHostError::CreateRefreshLock { path, source }
562 }
563 CacheFileError::ReadRefreshLock { path, source } => {
564 SubnetCatalogHostError::ReadRefreshLock { path, source }
565 }
566 CacheFileError::ParseRefreshLock { path, source } => {
567 SubnetCatalogHostError::ParseRefreshLock { path, source }
568 }
569 CacheFileError::WriteRefreshLock { path, source } => {
570 SubnetCatalogHostError::WriteRefreshLock { path, source }
571 }
572 CacheFileError::RemoveRefreshLock { path, source } => {
573 SubnetCatalogHostError::RemoveRefreshLock { path, source }
574 }
575 CacheFileError::RefreshAlreadyInProgress {
576 path,
577 started_at_unix_ms,
578 } => SubnetCatalogHostError::RefreshAlreadyInProgress {
579 path,
580 started_at_unix_ms,
581 },
582 CacheFileError::WriteTemp { path, source } => {
583 SubnetCatalogHostError::WriteCatalogTemp { path, source }
584 }
585 CacheFileError::SyncTemp { path, source } => {
586 SubnetCatalogHostError::SyncCatalogTemp { path, source }
587 }
588 CacheFileError::Replace {
589 temp_path,
590 target_path,
591 source,
592 } => SubnetCatalogHostError::ReplaceCatalog {
593 temp_path,
594 catalog_path: target_path,
595 source,
596 },
597 CacheFileError::SyncDirectory { path, source } => {
598 SubnetCatalogHostError::SyncCatalogDirectory { path, source }
599 }
600 CacheFileError::WriteOutput { path, source } => {
601 SubnetCatalogHostError::WriteRefreshOutput { path, source }
602 }
603 CacheFileError::SyncOutput { path, source } => {
604 SubnetCatalogHostError::SyncRefreshOutput { path, source }
605 }
606 }
607}
608
609#[must_use]
610pub fn subnet_catalog_list_report_text(report: &SubnetCatalogListReport) -> String {
611 let headers = [
612 "SUBNET", "KIND", "SPEC", "GEO", "NODES", "CHG", "RANGES", "STALE",
613 ];
614 let rows = report
615 .subnets
616 .iter()
617 .map(|subnet| {
618 [
619 compact_principal(&subnet.subnet_principal),
620 subnet.subnet_kind.as_str().to_string(),
621 subnet.subnet_specialization.as_str().to_string(),
622 subnet.geographic_scope.as_str().to_string(),
623 subnet
624 .node_count
625 .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
626 yes_no(subnet.charges_apply_by_default).to_string(),
627 subnet.range_count.to_string(),
628 yes_no(report.catalog_stale).to_string(),
629 ]
630 })
631 .collect::<Vec<_>>();
632 let alignments = [
633 ColumnAlign::Left,
634 ColumnAlign::Left,
635 ColumnAlign::Left,
636 ColumnAlign::Left,
637 ColumnAlign::Right,
638 ColumnAlign::Left,
639 ColumnAlign::Right,
640 ColumnAlign::Left,
641 ];
642 let mut lines = Vec::new();
643 lines.push(format!(
644 "catalog: {} version {} stale {}",
645 report.network,
646 report.registry_version,
647 yes_no(report.catalog_stale)
648 ));
649 if rows.is_empty() {
650 lines.push("subnets: none".to_string());
651 return lines.join("\n");
652 }
653 lines.push(render_table(&headers, &rows, &alignments));
654 append_compact_range_lines(report, &mut lines);
655 lines.join("\n")
656}
657
658#[must_use]
659pub fn subnet_catalog_list_report_verbose_text(report: &SubnetCatalogListReport) -> String {
660 let headers = [
661 "SUBNET",
662 "KIND",
663 "SPECIALIZATION",
664 "GEO",
665 "NODES",
666 "CHARGES",
667 "RANGES",
668 "VERSION",
669 "FETCHED_AT",
670 "STALE",
671 ];
672 let rows = report
673 .subnets
674 .iter()
675 .map(|subnet| {
676 [
677 subnet.subnet_principal.clone(),
678 subnet.subnet_kind.as_str().to_string(),
679 subnet.subnet_specialization.as_str().to_string(),
680 subnet.geographic_scope.as_str().to_string(),
681 subnet
682 .node_count
683 .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
684 yes_no(subnet.charges_apply_by_default).to_string(),
685 subnet.range_count.to_string(),
686 report.registry_version.to_string(),
687 report.fetched_at.clone(),
688 yes_no(report.catalog_stale).to_string(),
689 ]
690 })
691 .collect::<Vec<_>>();
692 let alignments = [
693 ColumnAlign::Left,
694 ColumnAlign::Left,
695 ColumnAlign::Left,
696 ColumnAlign::Left,
697 ColumnAlign::Right,
698 ColumnAlign::Left,
699 ColumnAlign::Right,
700 ColumnAlign::Right,
701 ColumnAlign::Left,
702 ColumnAlign::Left,
703 ];
704 let mut lines = Vec::new();
705 lines.push(format!("catalog_path: {}", report.catalog_path));
706 lines.push(format!("stale_reason: {}", report.stale_reason));
707 if rows.is_empty() {
708 lines.push("subnets: none".to_string());
709 return lines.join("\n");
710 }
711 lines.push(render_table(&headers, &rows, &alignments));
712 append_range_lines(report, &mut lines);
713 lines.join("\n")
714}
715
716#[must_use]
717pub fn subnet_catalog_info_report_text(report: &SubnetCatalogInfoReport) -> String {
718 let mut lines = Vec::new();
719 lines.push(format!("input_principal: {}", report.input_principal));
720 lines.push(format!("resolved_as: {}", report.resolved_as));
721 lines.push(format!("resolved_from: {}", report.resolved_from));
722 lines.push(format!("subnet_principal: {}", report.subnet_principal));
723 lines.push(format!("subnet_kind: {}", report.subnet_kind.as_str()));
724 lines.push(format!(
725 "subnet_kind_source: {}",
726 report.subnet_kind_source.as_str()
727 ));
728 lines.push(format!(
729 "subnet_specialization: {}",
730 report.subnet_specialization.as_str()
731 ));
732 lines.push(format!(
733 "subnet_specialization_source: {}",
734 report.subnet_specialization_source.as_str()
735 ));
736 lines.push(format!(
737 "geographic_scope: {}",
738 report.geographic_scope.as_str()
739 ));
740 lines.push(format!(
741 "geographic_scope_source: {}",
742 report.geographic_scope_source.as_str()
743 ));
744 lines.push(format!("subnet_label: {}", report.subnet_label));
745 lines.push(format!(
746 "subnet_label_source: {}",
747 report.subnet_label_source.as_str()
748 ));
749 lines.push(format!(
750 "node_count: {}",
751 report
752 .node_count
753 .map_or_else(|| "unknown".to_string(), |count| count.to_string())
754 ));
755 lines.push(format!(
756 "charges_apply_to_subject: {}",
757 yes_no(report.charges_apply_to_subject)
758 ));
759 lines.push(format!(
760 "charge_applicability_reason: {}",
761 report.charge_applicability_reason
762 ));
763 lines.push(format!(
764 "registry_canister_id: {}",
765 report.registry_canister_id
766 ));
767 lines.push(format!("registry_version: {}", report.registry_version));
768 lines.push(format!(
769 "catalog_schema_version: {}",
770 report.catalog_schema_version
771 ));
772 lines.push(format!("catalog_path: {}", report.catalog_path));
773 lines.push(format!("fetched_at: {}", report.fetched_at));
774 lines.push(format!("catalog_stale: {}", yes_no(report.catalog_stale)));
775 lines.push(format!("stale_reason: {}", report.stale_reason));
776 lines.push(format!("resolver_backend: {}", report.resolver_backend));
777 if let Some(canister) = &report.matched_canister_principal {
778 lines.push(format!("matched_canister_principal: {canister}"));
779 }
780 if let Some(range) = &report.matched_routing_range {
781 lines.push(format!(
782 "matched_routing_range: {}..{}",
783 range.start_canister_id, range.end_canister_id
784 ));
785 }
786 lines.push(format!(
787 "cycles_per_billion_instructions: {}",
788 report
789 .cycles_per_billion_instructions
790 .map_or_else(|| "not_applicable".to_string(), |cycles| cycles.to_string())
791 ));
792 if let Some(rate_source) = &report.rate_source {
793 lines.push(format!("rate_source: {rate_source}"));
794 }
795 if let Some(formula_version) = &report.formula_version {
796 lines.push(format!("formula_version: {formula_version}"));
797 }
798 lines.join("\n")
799}
800
801#[must_use]
802pub fn subnet_catalog_refresh_report_text(report: &SubnetCatalogRefreshReport) -> String {
803 [
804 format!("network: {}", report.network),
805 format!("catalog_path: {}", report.catalog_path),
806 format!("refresh_lock_path: {}", report.refresh_lock_path),
807 format!("registry_canister_id: {}", report.registry_canister_id),
808 format!("registry_version: {}", report.registry_version),
809 format!("fetched_at: {}", report.fetched_at),
810 format!("source_endpoint: {}", report.source_endpoint),
811 format!("fetched_by: {}", report.fetched_by),
812 format!("dry_run: {}", yes_no(report.dry_run)),
813 format!("wrote_catalog: {}", yes_no(report.wrote_catalog)),
814 format!(
815 "replaced_existing_catalog: {}",
816 yes_no(report.replaced_existing_catalog)
817 ),
818 format!("subnet_count: {}", report.subnet_count),
819 format!("routing_range_count: {}", report.routing_range_count),
820 ]
821 .join("\n")
822}
823
824fn enforce_mainnet_network(network: &str) -> Result<(), SubnetCatalogHostError> {
825 if network == MAINNET_NETWORK {
826 return Ok(());
827 }
828 Err(SubnetCatalogHostError::UnsupportedNetwork {
829 network: network.to_string(),
830 })
831}
832
833trait SubnetCatalogRefreshSource {
834 fn fetch_catalog(
835 &self,
836 request: &MainnetRegistryFetchRequest,
837 ) -> Result<SubnetCatalog, SubnetCatalogHostError>;
838}
839
840struct LiveNnsRegistryRefreshSource;
844
845impl SubnetCatalogRefreshSource for LiveNnsRegistryRefreshSource {
846 fn fetch_catalog(
847 &self,
848 request: &MainnetRegistryFetchRequest,
849 ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
850 Ok(fetch_mainnet_subnet_catalog(request)?)
851 }
852}
853
854fn subnet_matches_filters(subnet: &SubnetInfo, filters: SubnetCatalogFilters) -> bool {
855 filters.kind.is_none_or(|kind| subnet.subnet_kind == kind)
856 && filters
857 .specialization
858 .is_none_or(|specialization| subnet.subnet_specialization == specialization)
859 && filters
860 .geographic_scope
861 .is_none_or(|scope| subnet.geographic_scope == scope)
862}
863
864fn subnet_row(
865 catalog: &SubnetCatalog,
866 subnet: &SubnetInfo,
867 request: &SubnetCatalogListRequest,
868) -> SubnetCatalogSubnetRow {
869 let ranges = catalog.routing_ranges_for_subnet(&subnet.subnet_principal);
870 let range_count = ranges.len();
871 let shown_ranges = if request.show_ranges {
872 ranges
873 .into_iter()
874 .skip(request.range_offset)
875 .take(request.range_limit)
876 .cloned()
877 .collect::<Vec<_>>()
878 } else {
879 Vec::new()
880 };
881 SubnetCatalogSubnetRow {
882 subnet_principal: subnet.subnet_principal.clone(),
883 subnet_kind: subnet.subnet_kind,
884 subnet_kind_source: subnet.subnet_kind_source,
885 subnet_specialization: subnet.subnet_specialization,
886 subnet_specialization_source: subnet.subnet_specialization_source,
887 geographic_scope: subnet.geographic_scope,
888 geographic_scope_source: subnet.geographic_scope_source,
889 subnet_label: subnet.subnet_label.clone(),
890 subnet_label_source: subnet.subnet_label_source,
891 node_count: subnet.node_count,
892 charges_apply_by_default: subnet.charges_apply_by_default,
893 range_count,
894 ranges_shown: shown_ranges.len(),
895 range_offset: request.range_offset,
896 range_limit: request.range_limit,
897 ranges: shown_ranges,
898 }
899}
900
901fn charge_applicability(subject: ResolvedSubnetSubject, kind: SubnetKind) -> (bool, String) {
902 match kind {
903 SubnetKind::Application => (true, "charged_user_canister_subnet".to_string()),
904 SubnetKind::System if subject == ResolvedSubnetSubject::Subnet => {
905 (false, "system_subnet_core_canister".to_string())
906 }
907 SubnetKind::System => (false, "system_subnet_unknown_subject".to_string()),
908 SubnetKind::Unknown => (false, "unknown_subnet_type".to_string()),
909 }
910}
911
912fn catalog_cycles_per_billion(subnet: &SubnetInfo) -> Option<u128> {
913 if subnet.subnet_kind != SubnetKind::Application {
914 return None;
915 }
916 let node_count = u128::from(subnet.node_count?);
917 if node_count == 0 {
918 return None;
919 }
920 Some(ceil_div(
921 BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS * node_count,
922 13,
923 ))
924}
925
926const fn ceil_div(numerator: u128, denominator: u128) -> u128 {
927 numerator.div_ceil(denominator)
928}
929
930fn append_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
931 for subnet in &report.subnets {
932 if subnet.ranges.is_empty() {
933 continue;
934 }
935 lines.push(format!("ranges for {}:", subnet.subnet_principal));
936 for range in &subnet.ranges {
937 lines.push(format!(
938 " {}..{}",
939 range.start_canister_id, range.end_canister_id
940 ));
941 }
942 if subnet.ranges_shown < subnet.range_count {
943 lines.push(format!(
944 " showing {} of {} ranges; use --range-limit or --format json",
945 subnet.ranges_shown, subnet.range_count
946 ));
947 }
948 }
949}
950
951fn append_compact_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
952 for subnet in &report.subnets {
953 if subnet.ranges.is_empty() {
954 continue;
955 }
956 lines.push(format!(
957 "ranges for {}:",
958 compact_principal(&subnet.subnet_principal)
959 ));
960 for range in &subnet.ranges {
961 lines.push(format!(
962 " {}..{}",
963 compact_principal(&range.start_canister_id),
964 compact_principal(&range.end_canister_id)
965 ));
966 }
967 if subnet.ranges_shown < subnet.range_count {
968 lines.push(format!(
969 " showing {} of {} ranges; use --range-limit or --format json",
970 subnet.ranges_shown, subnet.range_count
971 ));
972 }
973 }
974}
975
976fn compact_principal(value: &str) -> String {
977 value.chars().take(5).collect()
978}
979
980fn parse_utc_timestamp_secs(value: &str) -> Option<u64> {
981 let value = value.strip_suffix('Z')?;
982 let (date, time) = value.split_once('T')?;
983 let mut date_parts = date.split('-');
984 let year = date_parts.next()?.parse::<i64>().ok()?;
985 let month = date_parts.next()?.parse::<u32>().ok()?;
986 let day = date_parts.next()?.parse::<u32>().ok()?;
987 if date_parts.next().is_some() {
988 return None;
989 }
990 let mut time_parts = time.split(':');
991 let hour = time_parts.next()?.parse::<u32>().ok()?;
992 let minute = time_parts.next()?.parse::<u32>().ok()?;
993 let second = time_parts.next()?.parse::<u32>().ok()?;
994 if time_parts.next().is_some()
995 || !(1..=12).contains(&month)
996 || !(1..=31).contains(&day)
997 || hour > 23
998 || minute > 59
999 || second > 59
1000 {
1001 return None;
1002 }
1003 let days = days_from_civil(year, month, day)?;
1004 let seconds = days
1005 .checked_mul(86_400)?
1006 .checked_add(i64::from(hour) * 3_600)?
1007 .checked_add(i64::from(minute) * 60)?
1008 .checked_add(i64::from(second))?;
1009 u64::try_from(seconds).ok()
1010}
1011
1012pub(crate) fn format_utc_timestamp_secs(value: u64) -> String {
1013 let days = i64::try_from(value / 86_400).unwrap_or(i64::MAX);
1014 let seconds_of_day = value % 86_400;
1015 let (year, month, day) = civil_from_days(days);
1016 let hour = seconds_of_day / 3_600;
1017 let minute = (seconds_of_day % 3_600) / 60;
1018 let second = seconds_of_day % 60;
1019 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1020}
1021
1022fn civil_from_days(days: i64) -> (i64, u32, u32) {
1023 let days = days + 719_468;
1024 let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1025 let day_of_era = days - era * 146_097;
1026 let year_of_era =
1027 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1028 let mut year = year_of_era + era * 400;
1029 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1030 let month_prime = (5 * day_of_year + 2) / 153;
1031 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1032 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1033 year += i64::from(month <= 2);
1034 (
1035 year,
1036 u32::try_from(month).expect("civil month is in u32 range"),
1037 u32::try_from(day).expect("civil day is in u32 range"),
1038 )
1039}
1040
1041fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1042 let month = i64::from(month);
1043 let day = i64::from(day);
1044 let year = year - i64::from(month <= 2);
1045 let era = if year >= 0 { year } else { year - 399 } / 400;
1046 let year_of_era = year - era * 400;
1047 let month_prime = month + if month > 2 { -3 } else { 9 };
1048 let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
1049 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1050 era.checked_mul(146_097)?
1051 .checked_add(day_of_era)?
1052 .checked_sub(719_468)
1053}
1054
1055const fn yes_no(value: bool) -> &'static str {
1056 if value { "yes" } else { "no" }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use crate::test_support::temp_dir;
1063 use canic_subnet_catalog::{
1064 CATALOG_SCHEMA_VERSION, ClassificationSource, GeographicScope,
1065 MAINNET_REGISTRY_CANISTER_ID, SubnetSpecialization,
1066 };
1067
1068 const SUBNET_A: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
1069 const SUBNET_B: &str = "aaaaa-aa";
1070 const CANISTER_A: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
1071
1072 #[test]
1073 fn catalog_path_lives_outside_deployment_state() {
1074 let root = PathBuf::from("/tmp/canic-project");
1075
1076 let path = subnet_catalog_path(&root, MAINNET_NETWORK);
1077
1078 assert_eq!(
1079 path,
1080 PathBuf::from("/tmp/canic-project/.canic/subnet-catalog/ic/catalog.json")
1081 );
1082 assert!(!path.display().to_string().contains("/deployments/"));
1083 assert!(!path.display().to_string().contains("/fleets/"));
1084 }
1085
1086 #[test]
1087 fn load_cached_catalog_rejects_non_mainnet_network() {
1088 let root = temp_dir("canic-subnet-host-network");
1089 let request = SubnetCatalogCacheRequest {
1090 icp_root: root.clone(),
1091 network: "local".to_string(),
1092 };
1093
1094 let err = load_cached_subnet_catalog(&request).expect_err("local rejected");
1095
1096 let _ = fs::remove_dir_all(root);
1097 std::assert_matches!(err, SubnetCatalogHostError::UnsupportedNetwork { .. });
1098 }
1099
1100 #[test]
1101 fn missing_catalog_error_explains_cached_only_slice() {
1102 let root = temp_dir("canic-subnet-host-missing");
1103 let request = SubnetCatalogCacheRequest {
1104 icp_root: root.clone(),
1105 network: MAINNET_NETWORK.to_string(),
1106 };
1107
1108 let err = load_cached_subnet_catalog(&request).expect_err("cache missing");
1109 let message = err.to_string();
1110
1111 let _ = fs::remove_dir_all(root);
1112 assert!(message.contains("Run `canic nns subnet refresh`"));
1113 assert!(message.contains("public Internet Computer mainnet catalog"));
1114 assert!(message.contains("canic nns subnet refresh"));
1115 }
1116
1117 #[test]
1118 fn list_report_loads_cached_catalog_and_caps_ranges() {
1119 let root = temp_dir("canic-subnet-host-list");
1120 write_catalog(&root, fixture_catalog());
1121 let request = list_request(&root);
1122
1123 let report = build_subnet_catalog_list_report(&request).expect("list report");
1124 let text = subnet_catalog_list_report_text(&report);
1125
1126 let _ = fs::remove_dir_all(root);
1127 assert_eq!(report.subnets.len(), 2);
1128 assert_eq!(report.subnets[0].range_count, 2);
1129 assert_eq!(report.subnets[0].ranges_shown, 1);
1130 assert!(text.contains("SUBNET"));
1131 assert!(text.contains("SPEC"));
1132 assert!(!text.contains("SPECIALIZATION"));
1133 for subnet in &report.subnets {
1134 assert!(text.contains(&compact_principal(&subnet.subnet_principal)));
1135 assert!(!text.contains(&subnet.subnet_principal));
1136 }
1137 assert!(!text.contains("FETCHED_AT"));
1138 assert!(text.contains("showing 1 of 2 ranges"));
1139 }
1140
1141 #[test]
1142 fn list_report_verbose_text_keeps_full_metadata() {
1143 let root = temp_dir("canic-subnet-host-list-verbose");
1144 write_catalog(&root, fixture_catalog());
1145 let request = list_request(&root);
1146
1147 let report = build_subnet_catalog_list_report(&request).expect("list report");
1148 let text = subnet_catalog_list_report_verbose_text(&report);
1149
1150 let _ = fs::remove_dir_all(root);
1151 assert!(text.contains("catalog_path:"));
1152 assert!(text.contains("SPECIALIZATION"));
1153 assert!(text.contains("FETCHED_AT"));
1154 assert!(text.contains(SUBNET_A));
1155 }
1156
1157 #[test]
1158 fn info_report_resolves_canister_and_marks_application_chargeable() {
1159 let root = temp_dir("canic-subnet-host-info");
1160 write_catalog(&root, fixture_catalog());
1161 let request = info_request(&root, CANISTER_A);
1162
1163 let report = build_subnet_catalog_info_report(&request).expect("info report");
1164
1165 let _ = fs::remove_dir_all(root);
1166 assert_eq!(report.resolved_as, "canister");
1167 assert_eq!(report.subnet_principal, SUBNET_A);
1168 assert!(report.charges_apply_to_subject);
1169 assert_eq!(
1170 report.charge_applicability_reason,
1171 "charged_user_canister_subnet"
1172 );
1173 assert_eq!(report.cycles_per_billion_instructions, Some(2_615_384_616));
1174 }
1175
1176 #[test]
1177 fn info_report_resolves_unique_subnet_prefix() {
1178 let root = temp_dir("canic-subnet-host-info-subnet-prefix");
1179 write_catalog(&root, fixture_catalog());
1180 let request = info_request(&root, "rwl");
1181
1182 let report = build_subnet_catalog_info_report(&request).expect("info report");
1183
1184 let _ = fs::remove_dir_all(root);
1185 assert_eq!(report.input_principal, "rwl");
1186 assert_eq!(report.resolved_as, "subnet");
1187 assert_eq!(report.resolved_from, "subnet_principal_prefix");
1188 assert_eq!(report.subnet_principal, SUBNET_A);
1189 assert_eq!(report.matched_canister_principal, None);
1190 }
1191
1192 #[test]
1193 fn info_report_rejects_canister_prefix() {
1194 let root = temp_dir("canic-subnet-host-info-canister-prefix");
1195 write_catalog(&root, fixture_catalog());
1196 let request = info_request(&root, "ryj");
1197
1198 let err = build_subnet_catalog_info_report(&request).expect_err("canister prefix rejected");
1199
1200 let _ = fs::remove_dir_all(root);
1201 std::assert_matches!(
1202 err,
1203 SubnetCatalogHostError::Catalog(CatalogError::PrincipalPrefixNotFound { prefix })
1204 if prefix == "ryj"
1205 );
1206 }
1207
1208 #[test]
1209 fn system_subnet_has_no_catalog_rate() {
1210 let root = temp_dir("canic-subnet-host-system");
1211 let mut catalog = fixture_catalog();
1212 catalog.subnets[0].subnet_kind = SubnetKind::System;
1213 catalog.subnets[0].charges_apply_by_default = false;
1214 write_catalog(&root, catalog);
1215 let request = info_request(&root, CANISTER_A);
1216
1217 let report = build_subnet_catalog_info_report(&request).expect("info report");
1218
1219 let _ = fs::remove_dir_all(root);
1220 assert!(!report.charges_apply_to_subject);
1221 assert_eq!(
1222 report.charge_applicability_reason,
1223 "system_subnet_unknown_subject"
1224 );
1225 assert_eq!(report.cycles_per_billion_instructions, None);
1226 }
1227
1228 #[test]
1229 fn stale_status_is_deterministic() {
1230 let catalog = fixture_catalog();
1231 let fresh = catalog_stale_status(&catalog, 1_780_531_300, 200);
1232 let stale = catalog_stale_status(&catalog, 1_780_531_501, 200);
1233
1234 assert!(!fresh.catalog_stale);
1235 assert!(stale.catalog_stale);
1236 }
1237
1238 #[test]
1239 fn stale_duration_accepts_units() {
1240 assert_eq!(parse_stale_after_duration("7d").expect("days"), 604_800);
1241 assert_eq!(parse_stale_after_duration("2h").expect("hours"), 7_200);
1242 assert_eq!(parse_stale_after_duration("30m").expect("minutes"), 1_800);
1243 assert_eq!(parse_stale_after_duration("90s").expect("seconds"), 90);
1244 assert_eq!(parse_stale_after_duration("42").expect("bare"), 42);
1245 std::assert_matches!(
1246 parse_stale_after_duration("0d"),
1247 Err(SubnetCatalogHostError::InvalidStaleDuration { .. })
1248 );
1249 }
1250
1251 #[test]
1252 fn refresh_writes_catalog_atomically_and_removes_lock() {
1253 let root = temp_dir("canic-subnet-host-refresh");
1254 let mut catalog = fixture_catalog();
1255 catalog.registry_version = 987_654;
1256 catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1257 catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1258 let source = FixtureRefreshSource::ok(catalog);
1259 let request = refresh_request(&root);
1260
1261 let report =
1262 refresh_subnet_catalog_with_source(&request, &source).expect("refresh catalog");
1263 let cached = load_cached_subnet_catalog(&cache_request(&root)).expect("cached catalog");
1264 let lock_path = PathBuf::from(&report.refresh_lock_path);
1265
1266 let _ = fs::remove_dir_all(root);
1267 assert!(report.wrote_catalog);
1268 assert!(!report.replaced_existing_catalog);
1269 assert_eq!(report.registry_version, 987_654);
1270 assert_eq!(cached.catalog.registry_version, 987_654);
1271 assert!(!lock_path.exists());
1272 }
1273
1274 #[test]
1275 fn refresh_dry_run_writes_output_without_replacing_cache() {
1276 let root = temp_dir("canic-subnet-host-refresh-dry-run");
1277 let mut catalog = fixture_catalog();
1278 catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1279 catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1280 let output_path = root.join("catalog-export.json");
1281 let source = FixtureRefreshSource::ok(catalog);
1282 let mut request = refresh_request(&root);
1283 request.dry_run = true;
1284 request.output_path = Some(output_path.clone());
1285
1286 let report = refresh_subnet_catalog_with_source(&request, &source).expect("dry-run");
1287
1288 assert!(!report.wrote_catalog);
1289 assert!(!subnet_catalog_path(&request.cache.icp_root, MAINNET_NETWORK).exists());
1290 assert!(output_path.exists());
1291 let _ = fs::remove_dir_all(root);
1292 }
1293
1294 #[test]
1295 fn refresh_failure_preserves_existing_catalog_and_removes_lock() {
1296 let root = temp_dir("canic-subnet-host-refresh-failure");
1297 write_catalog(&root, fixture_catalog());
1298 let source = FixtureRefreshSource::err();
1299 let request = refresh_request(&root);
1300
1301 let err = refresh_subnet_catalog_with_source(&request, &source).expect_err("refresh fails");
1302 let cached = load_cached_subnet_catalog(&cache_request(&root)).expect("cached catalog");
1303 let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1304
1305 std::assert_matches!(err, SubnetCatalogHostError::InvalidStaleDuration { .. });
1306 assert_eq!(cached.catalog.registry_version, 123_456);
1307 assert!(!lock_path.exists());
1308 let _ = fs::remove_dir_all(root);
1309 }
1310
1311 #[test]
1312 fn refresh_existing_fresh_lock_fails_fast() {
1313 let root = temp_dir("canic-subnet-host-refresh-locked");
1314 let request = refresh_request(&root);
1315 let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1316 write_refresh_lock_for_test(&lock_path, &request, request.now_unix_secs * 1_000);
1317
1318 let err = refresh_subnet_catalog_with_source(&request, &FixtureRefreshSource::err())
1319 .expect_err("lock held");
1320
1321 let _ = fs::remove_dir_all(root);
1322 std::assert_matches!(err, SubnetCatalogHostError::RefreshAlreadyInProgress { .. });
1323 }
1324
1325 #[test]
1326 fn refresh_removes_stale_lock_and_retries_once() {
1327 let root = temp_dir("canic-subnet-host-refresh-stale-lock");
1328 let mut catalog = fixture_catalog();
1329 catalog.fetched_at = "1970-01-01T00:00:00Z".to_string();
1330 catalog.source_endpoint = DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string();
1331 let source = FixtureRefreshSource::ok(catalog);
1332 let request = refresh_request(&root);
1333 let lock_path = subnet_catalog_refresh_lock_path(&root, MAINNET_NETWORK);
1334 let stale_started_at =
1335 (request.now_unix_secs - request.lock_stale_after_seconds - 1) * 1_000;
1336 write_refresh_lock_for_test(&lock_path, &request, stale_started_at);
1337
1338 let report =
1339 refresh_subnet_catalog_with_source(&request, &source).expect("stale lock removed");
1340
1341 assert!(report.wrote_catalog);
1342 assert!(!lock_path.exists());
1343 let _ = fs::remove_dir_all(root);
1344 }
1345
1346 #[test]
1347 fn utc_timestamp_formatter_is_deterministic() {
1348 assert_eq!(format_utc_timestamp_secs(0), "1970-01-01T00:00:00Z");
1349 assert_eq!(
1350 format_utc_timestamp_secs(1_780_531_200),
1351 "2026-06-04T00:00:00Z"
1352 );
1353 }
1354
1355 fn list_request(root: &Path) -> SubnetCatalogListRequest {
1356 SubnetCatalogListRequest {
1357 cache: cache_request(root),
1358 now_unix_secs: 1_780_531_300,
1359 stale_after_seconds: DEFAULT_STALE_AFTER_SECONDS,
1360 filters: SubnetCatalogFilters::default(),
1361 show_ranges: true,
1362 range_limit: 1,
1363 range_offset: 0,
1364 }
1365 }
1366
1367 fn info_request(root: &Path, input: &str) -> SubnetCatalogInfoRequest {
1368 SubnetCatalogInfoRequest {
1369 cache: cache_request(root),
1370 input: input.to_string(),
1371 forced: None,
1372 resolved_target: None,
1373 now_unix_secs: 1_780_531_300,
1374 stale_after_seconds: DEFAULT_STALE_AFTER_SECONDS,
1375 }
1376 }
1377
1378 fn cache_request(root: &Path) -> SubnetCatalogCacheRequest {
1379 SubnetCatalogCacheRequest {
1380 icp_root: root.to_path_buf(),
1381 network: MAINNET_NETWORK.to_string(),
1382 }
1383 }
1384
1385 fn write_catalog(root: &Path, catalog: SubnetCatalog) {
1386 let path = subnet_catalog_path(root, MAINNET_NETWORK);
1387 fs::create_dir_all(path.parent().expect("catalog parent")).expect("create parent");
1388 fs::write(
1389 path,
1390 serde_json::to_vec_pretty(&catalog).expect("serialize catalog"),
1391 )
1392 .expect("write catalog");
1393 }
1394
1395 fn refresh_request(root: &Path) -> SubnetCatalogRefreshRequest {
1396 SubnetCatalogRefreshRequest {
1397 cache: cache_request(root),
1398 source_endpoint: DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT.to_string(),
1399 now_unix_secs: 1_780_531_200,
1400 lock_stale_after_seconds: DEFAULT_REFRESH_LOCK_STALE_SECONDS,
1401 dry_run: false,
1402 output_path: None,
1403 }
1404 }
1405
1406 fn write_refresh_lock_for_test(
1407 lock_path: &Path,
1408 request: &SubnetCatalogRefreshRequest,
1409 started_at_unix_ms: u64,
1410 ) {
1411 fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("create parent");
1412 let lock = serde_json::json!({
1413 "schema_version": 1,
1414 "network": request.cache.network.clone(),
1415 "pid": 12345,
1416 "started_at_unix_ms": started_at_unix_ms,
1417 "target_path": subnet_catalog_path(&request.cache.icp_root, &request.cache.network)
1418 .display()
1419 .to_string(),
1420 });
1421 fs::write(
1422 lock_path,
1423 serde_json::to_vec_pretty(&lock).expect("serialize lock"),
1424 )
1425 .expect("write lock");
1426 }
1427
1428 struct FixtureRefreshSource {
1432 catalog: Option<SubnetCatalog>,
1433 fail: bool,
1434 }
1435
1436 impl FixtureRefreshSource {
1437 fn ok(catalog: SubnetCatalog) -> Self {
1438 Self {
1439 catalog: Some(catalog),
1440 fail: false,
1441 }
1442 }
1443
1444 fn err() -> Self {
1445 Self {
1446 catalog: None,
1447 fail: true,
1448 }
1449 }
1450 }
1451
1452 impl SubnetCatalogRefreshSource for FixtureRefreshSource {
1453 fn fetch_catalog(
1454 &self,
1455 _request: &MainnetRegistryFetchRequest,
1456 ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
1457 if self.fail {
1458 return Err(SubnetCatalogHostError::InvalidStaleDuration {
1459 value: "fixture".to_string(),
1460 });
1461 }
1462 Ok(self.catalog.clone().expect("fixture catalog"))
1463 }
1464 }
1465
1466 fn fixture_catalog() -> SubnetCatalog {
1467 SubnetCatalog {
1468 catalog_schema_version: CATALOG_SCHEMA_VERSION,
1469 network: MAINNET_NETWORK.to_string(),
1470 registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
1471 registry_version: 123_456,
1472 fetched_at: "2026-06-04T00:00:00Z".to_string(),
1473 fetched_by: "fixture".to_string(),
1474 source_endpoint: "https://icp-api.io".to_string(),
1475 resolver_backend: "local-nns-subnet-catalog".to_string(),
1476 subnets: vec![
1477 SubnetInfo {
1478 subnet_principal: SUBNET_A.to_string(),
1479 subnet_kind: SubnetKind::Application,
1480 subnet_kind_source: ClassificationSource::Registry,
1481 subnet_specialization: SubnetSpecialization::Fiduciary,
1482 subnet_specialization_source: ClassificationSource::Curated,
1483 geographic_scope: GeographicScope::Global,
1484 geographic_scope_source: ClassificationSource::Curated,
1485 subnet_label: "fiduciary".to_string(),
1486 subnet_label_source: ClassificationSource::Curated,
1487 node_count: Some(34),
1488 charges_apply_by_default: true,
1489 },
1490 SubnetInfo {
1491 subnet_principal: SUBNET_B.to_string(),
1492 subnet_kind: SubnetKind::System,
1493 subnet_kind_source: ClassificationSource::Registry,
1494 subnet_specialization: SubnetSpecialization::None,
1495 subnet_specialization_source: ClassificationSource::Curated,
1496 geographic_scope: GeographicScope::Global,
1497 geographic_scope_source: ClassificationSource::Curated,
1498 subnet_label: "system".to_string(),
1499 subnet_label_source: ClassificationSource::Curated,
1500 node_count: Some(13),
1501 charges_apply_by_default: false,
1502 },
1503 ],
1504 routing_ranges: vec![
1505 RoutingRange {
1506 start_canister_id: CANISTER_A.to_string(),
1507 end_canister_id: CANISTER_A.to_string(),
1508 subnet_principal: SUBNET_A.to_string(),
1509 },
1510 RoutingRange {
1511 start_canister_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(),
1512 end_canister_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(),
1513 subnet_principal: SUBNET_A.to_string(),
1514 },
1515 RoutingRange {
1516 start_canister_id: "r7inp-6aaaa-aaaaa-aaabq-cai".to_string(),
1517 end_canister_id: "r7inp-6aaaa-aaaaa-aaabq-cai".to_string(),
1518 subnet_principal: SUBNET_B.to_string(),
1519 },
1520 ],
1521 }
1522 }
1523}