1use crate::{
2 cache_file::{
3 CacheFileError, JsonCacheReport, LoadJsonCacheErrorHandlers, LoadJsonCacheRequest,
4 RefreshCacheWriteRequest, load_json_cache, write_json_refresh_cache,
5 },
6 nns_render::{compact_text, text_or_dash, yes_no},
7 subnet_catalog::format_utc_timestamp_secs,
8 table::{ColumnAlign, render_table},
9};
10use canic_ic_registry::{
11 DEFAULT_MAINNET_ENDPOINT, MainnetNodeList, MainnetRegistryFetchRequest, RegistryFetchError,
12 fetch_mainnet_node_list,
13};
14use canic_subnet_catalog::{MAINNET_NETWORK, canonical_principal_text};
15use serde::{Deserialize, Serialize};
16use std::{
17 io,
18 path::{Path, PathBuf},
19};
20use thiserror::Error as ThisError;
21
22pub const DEFAULT_NNS_NODE_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
23pub const DEFAULT_NODE_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
24pub const NNS_NODE_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
25pub const NNS_NODE_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
26pub const NNS_NODE_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
27pub const NNS_NODE_SUBNET_KIND_APPLICATION: &str = "application";
28pub const NNS_NODE_SUBNET_KIND_CLOUD_ENGINE: &str = "cloud_engine";
29pub const NNS_NODE_SUBNET_KIND_SYSTEM: &str = "system";
30pub const NNS_NODE_SUBNET_KIND_UNKNOWN: &str = "unknown";
31const COMPACT_PRINCIPAL_CHARS: usize = 5;
32
33#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct NnsNodeCacheRequest {
38 pub icp_root: PathBuf,
39 pub network: String,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct NnsNodeListRequest {
47 pub cache: NnsNodeCacheRequest,
48 pub source_endpoint: String,
49 pub now_unix_secs: u64,
50 pub filters: NnsNodeListFilters,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct NnsNodeInfoRequest {
58 pub cache: NnsNodeCacheRequest,
59 pub source_endpoint: String,
60 pub input: String,
61 pub now_unix_secs: u64,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct NnsNodeRefreshRequest {
69 pub cache: NnsNodeCacheRequest,
70 pub source_endpoint: String,
71 pub now_unix_secs: u64,
72 pub lock_stale_after_seconds: u64,
73 pub dry_run: bool,
74 pub output_path: Option<PathBuf>,
75}
76
77#[derive(Clone, Debug, Default, Eq, PartialEq)]
81pub struct NnsNodeListFilters {
82 pub subnet: Option<String>,
83 pub subnet_kind: Option<String>,
84 pub data_center: Option<String>,
85 pub node_provider: Option<String>,
86 pub node_operator: Option<String>,
87}
88
89impl NnsNodeListFilters {
90 #[must_use]
91 pub const fn is_empty(&self) -> bool {
92 self.subnet.is_none()
93 && self.subnet_kind.is_none()
94 && self.data_center.is_none()
95 && self.node_provider.is_none()
96 && self.node_operator.is_none()
97 }
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct CachedNnsNodeReport {
105 pub path: PathBuf,
106 pub report: NnsNodeListReport,
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct NnsNodeListReport {
114 pub schema_version: u32,
115 pub network: String,
116 pub registry_canister_id: String,
117 pub registry_version: u64,
118 pub fetched_at: String,
119 pub source_endpoint: String,
120 pub fetched_by: String,
121 pub node_count: usize,
122 pub nodes: Vec<NnsNodeRow>,
123}
124
125impl JsonCacheReport for NnsNodeListReport {
126 fn schema_version(&self) -> u32 {
127 self.schema_version
128 }
129
130 fn network(&self) -> &str {
131 &self.network
132 }
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
139pub struct NnsNodeRow {
140 pub node_principal: String,
141 pub node_operator_principal: String,
142 pub node_provider_principal: String,
143 pub subnet_principal: String,
144 pub subnet_kind: String,
145 pub data_center_id: String,
146}
147
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
152pub struct NnsNodeInfoReport {
153 pub schema_version: u32,
154 pub input: String,
155 pub resolved_from: String,
156 pub network: String,
157 pub registry_canister_id: String,
158 pub registry_version: u64,
159 pub fetched_at: String,
160 pub source_endpoint: String,
161 pub fetched_by: String,
162 pub node_principal: String,
163 pub node_operator_principal: String,
164 pub node_provider_principal: String,
165 pub subnet_principal: String,
166 pub subnet_kind: String,
167 pub data_center_id: String,
168}
169
170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
174pub struct NnsNodeRefreshReport {
175 pub schema_version: u32,
176 pub network: String,
177 pub cache_path: String,
178 pub refresh_lock_path: String,
179 pub output_path: Option<String>,
180 pub registry_canister_id: String,
181 pub registry_version: u64,
182 pub fetched_at: String,
183 pub source_endpoint: String,
184 pub fetched_by: String,
185 pub dry_run: bool,
186 pub wrote_cache: bool,
187 pub replaced_existing_cache: bool,
188 pub node_count: usize,
189}
190
191#[derive(Debug, ThisError)]
195pub enum NnsNodeHostError {
196 #[error(
197 "`canic nns node` supports only the mainnet `ic` network\n\nThe NNS node list is derived from public Internet Computer mainnet registry records.\nLocal replica NNS registry discovery is not implemented yet.\n\nTry:\n canic --network ic nns node list"
198 )]
199 UnsupportedNetwork { network: String },
200
201 #[error("node cache is missing at {}", path.display())]
202 MissingCache { path: PathBuf },
203
204 #[error("failed to read node cache at {}: {source}", path.display())]
205 ReadCache { path: PathBuf, source: io::Error },
206
207 #[error("failed to parse node cache at {}: {source}", path.display())]
208 ParseCache {
209 path: PathBuf,
210 source: serde_json::Error,
211 },
212
213 #[error("failed to serialize node cache JSON for {}: {source}", path.display())]
214 SerializeCache {
215 path: PathBuf,
216 source: serde_json::Error,
217 },
218
219 #[error("unsupported node cache schema version {version}; expected {expected}")]
220 UnsupportedCacheSchemaVersion { version: u32, expected: u32 },
221
222 #[error("cached node network mismatch: path is for {requested}, report is for {actual}")]
223 NetworkMismatch { requested: String, actual: String },
224
225 #[error("node refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
226 RefreshAlreadyInProgress {
227 path: PathBuf,
228 started_at_unix_ms: u64,
229 },
230
231 #[error("failed to create node cache directory at {}: {source}", path.display())]
232 CreateCacheDirectory { path: PathBuf, source: io::Error },
233
234 #[error("failed to create node refresh lock at {}: {source}", path.display())]
235 CreateRefreshLock { path: PathBuf, source: io::Error },
236
237 #[error("failed to read node refresh lock at {}: {source}", path.display())]
238 ReadRefreshLock { path: PathBuf, source: io::Error },
239
240 #[error("failed to parse node refresh lock at {}: {source}", path.display())]
241 ParseRefreshLock {
242 path: PathBuf,
243 source: serde_json::Error,
244 },
245
246 #[error("failed to write node refresh lock at {}: {source}", path.display())]
247 WriteRefreshLock { path: PathBuf, source: io::Error },
248
249 #[error("failed to remove node refresh lock at {}: {source}", path.display())]
250 RemoveRefreshLock { path: PathBuf, source: io::Error },
251
252 #[error("live NNS node refresh failed: {0}")]
253 NnsQuery(#[from] RegistryFetchError),
254
255 #[error("failed to write node cache temp file at {}: {source}", path.display())]
256 WriteCacheTemp { path: PathBuf, source: io::Error },
257
258 #[error("failed to sync node cache temp file at {}: {source}", path.display())]
259 SyncCacheTemp { path: PathBuf, source: io::Error },
260
261 #[error("failed to replace node cache at {} from {}: {source}", cache_path.display(), temp_path.display())]
262 ReplaceCache {
263 temp_path: PathBuf,
264 cache_path: PathBuf,
265 source: io::Error,
266 },
267
268 #[error("failed to sync node cache directory at {}: {source}", path.display())]
269 SyncCacheDirectory { path: PathBuf, source: io::Error },
270
271 #[error("failed to write refreshed node output at {}: {source}", path.display())]
272 WriteRefreshOutput { path: PathBuf, source: io::Error },
273
274 #[error("failed to sync refreshed node output at {}: {source}", path.display())]
275 SyncRefreshOutput { path: PathBuf, source: io::Error },
276
277 #[error("node {input:?} did not match the mainnet NNS node list")]
278 NodeNotFound { input: String },
279
280 #[error("node prefix {prefix:?} is ambiguous; matches: {matches:?}")]
281 AmbiguousNodePrefix {
282 prefix: String,
283 matches: Vec<String>,
284 },
285}
286
287#[must_use]
288pub fn nns_node_cache_path(icp_root: &Path, network: &str) -> PathBuf {
289 icp_root
290 .join(".canic")
291 .join("node")
292 .join(network)
293 .join("nodes.json")
294}
295
296#[must_use]
297pub fn nns_node_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
298 icp_root
299 .join(".canic")
300 .join("node")
301 .join(network)
302 .join("refresh.lock")
303}
304
305pub fn build_nns_node_list_report(
306 request: &NnsNodeListRequest,
307) -> Result<NnsNodeListReport, NnsNodeHostError> {
308 build_nns_node_list_report_with_source(request, &LiveNnsNodeSource)
309}
310
311pub fn build_nns_node_info_report(
312 request: &NnsNodeInfoRequest,
313) -> Result<NnsNodeInfoReport, NnsNodeHostError> {
314 build_nns_node_info_report_with_source(request, &LiveNnsNodeSource)
315}
316
317pub fn refresh_nns_node_report(
318 request: &NnsNodeRefreshRequest,
319) -> Result<NnsNodeRefreshReport, NnsNodeHostError> {
320 refresh_nns_node_report_with_source(request, &LiveNnsNodeSource)
321}
322
323fn load_cached_nns_node_report(
324 request: &NnsNodeCacheRequest,
325) -> Result<CachedNnsNodeReport, NnsNodeHostError> {
326 enforce_mainnet_network(&request.network)?;
327 let path = nns_node_cache_path(&request.icp_root, &request.network);
328 let cached = load_json_cache(
329 LoadJsonCacheRequest {
330 path,
331 network: &request.network,
332 expected_schema_version: NNS_NODE_LIST_REPORT_SCHEMA_VERSION,
333 },
334 LoadJsonCacheErrorHandlers {
335 missing_cache: |path| NnsNodeHostError::MissingCache { path },
336 read_cache: |path, source| NnsNodeHostError::ReadCache { path, source },
337 parse_cache: |path, source| NnsNodeHostError::ParseCache { path, source },
338 unsupported_schema: |version, expected| {
339 NnsNodeHostError::UnsupportedCacheSchemaVersion { version, expected }
340 },
341 network_mismatch: |requested, actual| NnsNodeHostError::NetworkMismatch {
342 requested,
343 actual,
344 },
345 },
346 )?;
347 Ok(CachedNnsNodeReport {
348 path: cached.path,
349 report: cached.report,
350 })
351}
352
353fn build_nns_node_list_report_with_source(
354 request: &NnsNodeListRequest,
355 source: &dyn NnsNodeSource,
356) -> Result<NnsNodeListReport, NnsNodeHostError> {
357 let report = match load_cached_nns_node_report(&request.cache) {
358 Ok(cached) => cached.report,
359 Err(NnsNodeHostError::MissingCache { .. }) => {
360 let refresh_request = NnsNodeRefreshRequest {
361 cache: request.cache.clone(),
362 source_endpoint: request.source_endpoint.clone(),
363 now_unix_secs: request.now_unix_secs,
364 lock_stale_after_seconds: DEFAULT_NODE_REFRESH_LOCK_STALE_SECONDS,
365 dry_run: false,
366 output_path: None,
367 };
368 let (report, _) = refresh_nns_node_cache_with_source(&refresh_request, source)?;
369 report
370 }
371 Err(err) => return Err(err),
372 };
373 Ok(filter_node_list_report(report, &request.filters))
374}
375
376fn build_nns_node_info_report_with_source(
377 request: &NnsNodeInfoRequest,
378 source: &dyn NnsNodeSource,
379) -> Result<NnsNodeInfoReport, NnsNodeHostError> {
380 let list_request = NnsNodeListRequest {
381 cache: request.cache.clone(),
382 source_endpoint: request.source_endpoint.clone(),
383 now_unix_secs: request.now_unix_secs,
384 filters: NnsNodeListFilters::default(),
385 };
386 let report = build_nns_node_list_report_with_source(&list_request, source)?;
387 let (node, resolved_from) = resolve_node(&report, &request.input)?;
388 Ok(NnsNodeInfoReport {
389 schema_version: NNS_NODE_INFO_REPORT_SCHEMA_VERSION,
390 input: request.input.clone(),
391 resolved_from,
392 network: report.network,
393 registry_canister_id: report.registry_canister_id,
394 registry_version: report.registry_version,
395 fetched_at: report.fetched_at,
396 source_endpoint: report.source_endpoint,
397 fetched_by: report.fetched_by,
398 node_principal: node.node_principal,
399 node_operator_principal: node.node_operator_principal,
400 node_provider_principal: node.node_provider_principal,
401 subnet_principal: node.subnet_principal,
402 subnet_kind: node.subnet_kind,
403 data_center_id: node.data_center_id,
404 })
405}
406
407fn refresh_nns_node_report_with_source(
408 request: &NnsNodeRefreshRequest,
409 source: &dyn NnsNodeSource,
410) -> Result<NnsNodeRefreshReport, NnsNodeHostError> {
411 refresh_nns_node_cache_with_source(request, source).map(|(_, report)| report)
412}
413
414fn refresh_nns_node_cache_with_source(
415 request: &NnsNodeRefreshRequest,
416 source: &dyn NnsNodeSource,
417) -> Result<(NnsNodeListReport, NnsNodeRefreshReport), NnsNodeHostError> {
418 enforce_mainnet_network(&request.cache.network)?;
419 let cache_path = nns_node_cache_path(&request.cache.icp_root, &request.cache.network);
420 let lock_path = nns_node_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
421 let report = fetch_nns_node_list_report_with_source(
422 &request.cache.network,
423 &request.source_endpoint,
424 request.now_unix_secs,
425 source,
426 )?;
427 let write_result = write_json_refresh_cache(
428 RefreshCacheWriteRequest {
429 cache_path: &cache_path,
430 lock_path: &lock_path,
431 network: &request.cache.network,
432 now_unix_secs: request.now_unix_secs,
433 lock_stale_after_seconds: request.lock_stale_after_seconds,
434 dry_run: request.dry_run,
435 output_path: request.output_path.as_deref(),
436 report: &report,
437 },
438 node_cache_error,
439 |path, source| NnsNodeHostError::SerializeCache { path, source },
440 )?;
441 let refresh_report = NnsNodeRefreshReport {
442 schema_version: NNS_NODE_REFRESH_REPORT_SCHEMA_VERSION,
443 network: report.network.clone(),
444 cache_path: write_result.cache_path,
445 refresh_lock_path: write_result.refresh_lock_path,
446 output_path: write_result.output_path,
447 registry_canister_id: report.registry_canister_id.clone(),
448 registry_version: report.registry_version,
449 fetched_at: report.fetched_at.clone(),
450 source_endpoint: report.source_endpoint.clone(),
451 fetched_by: report.fetched_by.clone(),
452 dry_run: request.dry_run,
453 wrote_cache: write_result.wrote_cache,
454 replaced_existing_cache: write_result.replaced_existing_cache,
455 node_count: report.node_count,
456 };
457 Ok((report, refresh_report))
458}
459
460fn fetch_nns_node_list_report_with_source(
461 network: &str,
462 source_endpoint: &str,
463 now_unix_secs: u64,
464 source: &dyn NnsNodeSource,
465) -> Result<NnsNodeListReport, NnsNodeHostError> {
466 enforce_mainnet_network(network)?;
467 let fetched_at = format_utc_timestamp_secs(now_unix_secs);
468 let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
469 fetch_request.endpoint = source_endpoint.to_string();
470 let list = source.fetch_nodes(&fetch_request)?;
471 Ok(node_report_from_list(list))
472}
473
474fn node_cache_error(err: CacheFileError) -> NnsNodeHostError {
475 match err {
476 CacheFileError::CreateDirectory { path, source } => {
477 NnsNodeHostError::CreateCacheDirectory { path, source }
478 }
479 CacheFileError::CreateRefreshLock { path, source } => {
480 NnsNodeHostError::CreateRefreshLock { path, source }
481 }
482 CacheFileError::ReadRefreshLock { path, source } => {
483 NnsNodeHostError::ReadRefreshLock { path, source }
484 }
485 CacheFileError::ParseRefreshLock { path, source } => {
486 NnsNodeHostError::ParseRefreshLock { path, source }
487 }
488 CacheFileError::WriteRefreshLock { path, source } => {
489 NnsNodeHostError::WriteRefreshLock { path, source }
490 }
491 CacheFileError::RemoveRefreshLock { path, source } => {
492 NnsNodeHostError::RemoveRefreshLock { path, source }
493 }
494 CacheFileError::RefreshAlreadyInProgress {
495 path,
496 started_at_unix_ms,
497 } => NnsNodeHostError::RefreshAlreadyInProgress {
498 path,
499 started_at_unix_ms,
500 },
501 CacheFileError::WriteTemp { path, source } => {
502 NnsNodeHostError::WriteCacheTemp { path, source }
503 }
504 CacheFileError::SyncTemp { path, source } => {
505 NnsNodeHostError::SyncCacheTemp { path, source }
506 }
507 CacheFileError::Replace {
508 temp_path,
509 target_path,
510 source,
511 } => NnsNodeHostError::ReplaceCache {
512 temp_path,
513 cache_path: target_path,
514 source,
515 },
516 CacheFileError::SyncDirectory { path, source } => {
517 NnsNodeHostError::SyncCacheDirectory { path, source }
518 }
519 CacheFileError::WriteOutput { path, source } => {
520 NnsNodeHostError::WriteRefreshOutput { path, source }
521 }
522 CacheFileError::SyncOutput { path, source } => {
523 NnsNodeHostError::SyncRefreshOutput { path, source }
524 }
525 }
526}
527
528#[must_use]
529pub fn nns_node_list_report_text(report: &NnsNodeListReport) -> String {
530 let mut lines = Vec::new();
531 lines.push(format!(
532 "nodes: {} count {} fetched_at {}",
533 report.network, report.node_count, report.fetched_at
534 ));
535 if report.nodes.is_empty() {
536 lines.push("nodes: none".to_string());
537 return lines.join("\n");
538 }
539 let headers = ["NODE", "OPERATOR", "PROVIDER", "SUBNET", "KIND", "DC"];
540 let rows = report
541 .nodes
542 .iter()
543 .map(|node| {
544 [
545 compact_text(&node.node_principal, COMPACT_PRINCIPAL_CHARS),
546 compact_text(&node.node_operator_principal, COMPACT_PRINCIPAL_CHARS),
547 compact_text(&node.node_provider_principal, COMPACT_PRINCIPAL_CHARS),
548 compact_text(&node.subnet_principal, COMPACT_PRINCIPAL_CHARS),
549 node.subnet_kind.clone(),
550 text_or_dash(Some(&node.data_center_id)).to_string(),
551 ]
552 })
553 .collect::<Vec<_>>();
554 let alignments = [
555 ColumnAlign::Left,
556 ColumnAlign::Left,
557 ColumnAlign::Left,
558 ColumnAlign::Left,
559 ColumnAlign::Left,
560 ColumnAlign::Left,
561 ];
562 lines.push(render_table(&headers, &rows, &alignments));
563 lines.join("\n")
564}
565
566#[must_use]
567pub fn nns_node_list_report_verbose_text(report: &NnsNodeListReport) -> String {
568 let mut lines = Vec::new();
569 lines.push(format!("source_endpoint: {}", report.source_endpoint));
570 lines.push(format!("fetched_by: {}", report.fetched_by));
571 if report.nodes.is_empty() {
572 lines.push("nodes: none".to_string());
573 return lines.join("\n");
574 }
575 let headers = [
576 "NODE",
577 "OPERATOR",
578 "PROVIDER",
579 "SUBNET",
580 "KIND",
581 "DC",
582 "REGISTRY_VERSION",
583 "FETCHED_AT",
584 ];
585 let rows = report
586 .nodes
587 .iter()
588 .map(|node| {
589 [
590 node.node_principal.clone(),
591 node.node_operator_principal.clone(),
592 node.node_provider_principal.clone(),
593 node.subnet_principal.clone(),
594 node.subnet_kind.clone(),
595 text_or_dash(Some(&node.data_center_id)).to_string(),
596 report.registry_version.to_string(),
597 report.fetched_at.clone(),
598 ]
599 })
600 .collect::<Vec<_>>();
601 let alignments = [
602 ColumnAlign::Left,
603 ColumnAlign::Left,
604 ColumnAlign::Left,
605 ColumnAlign::Left,
606 ColumnAlign::Left,
607 ColumnAlign::Left,
608 ColumnAlign::Right,
609 ColumnAlign::Left,
610 ];
611 lines.push(render_table(&headers, &rows, &alignments));
612 lines.join("\n")
613}
614
615#[must_use]
616pub fn nns_node_info_report_text(report: &NnsNodeInfoReport) -> String {
617 [
618 format!("input: {}", report.input),
619 format!("resolved_from: {}", report.resolved_from),
620 format!("node_principal: {}", report.node_principal),
621 format!(
622 "node_operator_principal: {}",
623 report.node_operator_principal
624 ),
625 format!(
626 "node_provider_principal: {}",
627 report.node_provider_principal
628 ),
629 format!("subnet_principal: {}", report.subnet_principal),
630 format!("subnet_kind: {}", report.subnet_kind),
631 format!(
632 "data_center_id: {}",
633 text_or_dash(Some(&report.data_center_id))
634 ),
635 format!("registry_canister_id: {}", report.registry_canister_id),
636 format!("registry_version: {}", report.registry_version),
637 format!("network: {}", report.network),
638 format!("fetched_at: {}", report.fetched_at),
639 format!("source_endpoint: {}", report.source_endpoint),
640 format!("fetched_by: {}", report.fetched_by),
641 ]
642 .join("\n")
643}
644
645#[must_use]
646pub fn nns_node_refresh_report_text(report: &NnsNodeRefreshReport) -> String {
647 [
648 format!("network: {}", report.network),
649 format!("cache_path: {}", report.cache_path),
650 format!("refresh_lock_path: {}", report.refresh_lock_path),
651 format!("registry_canister_id: {}", report.registry_canister_id),
652 format!("registry_version: {}", report.registry_version),
653 format!("fetched_at: {}", report.fetched_at),
654 format!("source_endpoint: {}", report.source_endpoint),
655 format!("fetched_by: {}", report.fetched_by),
656 format!("dry_run: {}", yes_no(report.dry_run)),
657 format!("wrote_cache: {}", yes_no(report.wrote_cache)),
658 format!(
659 "replaced_existing_cache: {}",
660 yes_no(report.replaced_existing_cache)
661 ),
662 format!("node_count: {}", report.node_count),
663 ]
664 .join("\n")
665}
666
667fn node_report_from_list(list: MainnetNodeList) -> NnsNodeListReport {
668 let nodes = list
669 .nodes
670 .into_iter()
671 .map(|node| NnsNodeRow {
672 node_principal: node.principal,
673 node_operator_principal: node.node_operator_principal,
674 node_provider_principal: node.node_provider_principal,
675 subnet_principal: node.subnet_principal,
676 subnet_kind: node.subnet_kind,
677 data_center_id: node.data_center_id,
678 })
679 .collect::<Vec<_>>();
680 NnsNodeListReport {
681 schema_version: NNS_NODE_LIST_REPORT_SCHEMA_VERSION,
682 network: list.network,
683 registry_canister_id: list.registry_canister_id,
684 registry_version: list.registry_version,
685 fetched_at: list.fetched_at,
686 source_endpoint: list.source_endpoint,
687 fetched_by: list.fetched_by,
688 node_count: nodes.len(),
689 nodes,
690 }
691}
692
693fn filter_node_list_report(
694 mut report: NnsNodeListReport,
695 filters: &NnsNodeListFilters,
696) -> NnsNodeListReport {
697 if filters.is_empty() {
698 return report;
699 }
700 report
701 .nodes
702 .retain(|node| node_matches_filters(node, filters));
703 report.node_count = report.nodes.len();
704 report
705}
706
707fn node_matches_filters(node: &NnsNodeRow, filters: &NnsNodeListFilters) -> bool {
708 filters
709 .subnet
710 .as_deref()
711 .is_none_or(|filter| principal_filter_matches(&node.subnet_principal, filter))
712 && filters
713 .subnet_kind
714 .as_deref()
715 .is_none_or(|filter| text_filter_equals(&node.subnet_kind, filter))
716 && filters
717 .data_center
718 .as_deref()
719 .is_none_or(|filter| text_filter_starts_with(&node.data_center_id, filter))
720 && filters
721 .node_provider
722 .as_deref()
723 .is_none_or(|filter| principal_filter_matches(&node.node_provider_principal, filter))
724 && filters
725 .node_operator
726 .as_deref()
727 .is_none_or(|filter| principal_filter_matches(&node.node_operator_principal, filter))
728}
729
730fn principal_filter_matches(value: &str, filter: &str) -> bool {
731 let Some(filter) = non_empty_filter(filter) else {
732 return false;
733 };
734 if let Ok(principal) = canonical_principal_text(filter) {
735 value == principal
736 } else {
737 value.starts_with(&filter.to_ascii_lowercase())
738 }
739}
740
741fn text_filter_starts_with(value: &str, filter: &str) -> bool {
742 let Some(filter) = non_empty_filter(filter) else {
743 return false;
744 };
745 value
746 .to_ascii_lowercase()
747 .starts_with(&filter.to_ascii_lowercase())
748}
749
750fn text_filter_equals(value: &str, filter: &str) -> bool {
751 let Some(filter) = non_empty_filter(filter) else {
752 return false;
753 };
754 value.eq_ignore_ascii_case(filter)
755}
756
757fn non_empty_filter(filter: &str) -> Option<&str> {
758 let filter = filter.trim();
759 (!filter.is_empty()).then_some(filter)
760}
761
762trait NnsNodeSource {
766 fn fetch_nodes(
767 &self,
768 request: &MainnetRegistryFetchRequest,
769 ) -> Result<MainnetNodeList, NnsNodeHostError>;
770}
771
772struct LiveNnsNodeSource;
776
777impl NnsNodeSource for LiveNnsNodeSource {
778 fn fetch_nodes(
779 &self,
780 request: &MainnetRegistryFetchRequest,
781 ) -> Result<MainnetNodeList, NnsNodeHostError> {
782 Ok(fetch_mainnet_node_list(request)?)
783 }
784}
785
786fn enforce_mainnet_network(network: &str) -> Result<(), NnsNodeHostError> {
787 if network == MAINNET_NETWORK {
788 return Ok(());
789 }
790 Err(NnsNodeHostError::UnsupportedNetwork {
791 network: network.to_string(),
792 })
793}
794
795fn resolve_node(
796 report: &NnsNodeListReport,
797 input: &str,
798) -> Result<(NnsNodeRow, String), NnsNodeHostError> {
799 if let Ok(principal) = canonical_principal_text(input)
800 && let Some(node) = report
801 .nodes
802 .iter()
803 .find(|node| node.node_principal == principal)
804 {
805 return Ok((node.clone(), "node_principal".to_string()));
806 }
807
808 let prefix = input.trim().to_ascii_lowercase();
809 if prefix.is_empty() {
810 return Err(NnsNodeHostError::NodeNotFound {
811 input: input.to_string(),
812 });
813 }
814 let matches = report
815 .nodes
816 .iter()
817 .filter(|node| node.node_principal.starts_with(&prefix))
818 .cloned()
819 .collect::<Vec<_>>();
820 match matches.as_slice() {
821 [node] => Ok((node.clone(), "node_principal_prefix".to_string())),
822 [] => Err(NnsNodeHostError::NodeNotFound {
823 input: input.to_string(),
824 }),
825 _ => Err(NnsNodeHostError::AmbiguousNodePrefix {
826 prefix,
827 matches: matches
828 .into_iter()
829 .map(|node| node.node_principal)
830 .collect(),
831 }),
832 }
833}
834
835#[cfg(test)]
836mod tests;