Skip to main content

canic_host/nns_node/
mod.rs

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///
34/// NnsNodeCacheRequest
35///
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct NnsNodeCacheRequest {
38    pub icp_root: PathBuf,
39    pub network: String,
40}
41
42///
43/// NnsNodeListRequest
44///
45#[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///
54/// NnsNodeInfoRequest
55///
56#[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///
65/// NnsNodeRefreshRequest
66///
67#[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///
78/// NnsNodeListFilters
79///
80#[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///
101/// CachedNnsNodeReport
102///
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct CachedNnsNodeReport {
105    pub path: PathBuf,
106    pub report: NnsNodeListReport,
107}
108
109///
110/// NnsNodeListReport
111///
112#[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///
136/// NnsNodeRow
137///
138#[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///
149/// NnsNodeInfoReport
150///
151#[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///
171/// NnsNodeRefreshReport
172///
173#[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///
192/// NnsNodeHostError
193///
194#[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
762///
763/// NnsNodeSource
764///
765trait NnsNodeSource {
766    fn fetch_nodes(
767        &self,
768        request: &MainnetRegistryFetchRequest,
769    ) -> Result<MainnetNodeList, NnsNodeHostError>;
770}
771
772///
773/// LiveNnsNodeSource
774///
775struct 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;