purple_ssh/providers/mod.rs
1pub mod aws;
2pub mod aws_profile;
3pub mod aws_ssm;
4pub mod aws_sts;
5pub mod azure;
6pub mod config;
7mod digitalocean;
8pub mod gcp;
9mod hetzner;
10mod i3d;
11pub mod kind;
12mod leaseweb;
13mod linode;
14mod netbox;
15pub mod oracle;
16pub mod ovh;
17mod proxmox;
18pub mod scaleway;
19pub mod sync;
20mod tailscale;
21mod teleport;
22mod transip;
23mod upcloud;
24mod vultr;
25
26pub use kind::ProviderKind;
27
28use std::sync::atomic::{AtomicBool, Ordering};
29
30use log::{debug, error, warn};
31use thiserror::Error;
32
33/// A host discovered from a cloud provider API.
34#[derive(Debug, Clone, Default)]
35pub struct ProviderHost {
36 /// Provider-assigned server ID.
37 pub server_id: String,
38 /// Server name/label.
39 pub name: String,
40 /// Public IP address (IPv4 or IPv6) or a hostname the transport resolves.
41 pub ip: String,
42 /// Provider tags/labels.
43 pub tags: Vec<String>,
44 /// Provider metadata (region, plan, etc.) as key-value pairs.
45 pub metadata: Vec<(String, String)>,
46 /// SSH port when the provider knows it differs from 22.
47 pub port: Option<u16>,
48 /// Extra ssh_config directives the provider manages on this host, such as
49 /// a `ProxyCommand`. Written on add and kept current on later syncs.
50 pub directives: Vec<(String, String)>,
51 /// Directives the provider once wrote here and now wants gone.
52 pub retract_directives: Vec<RetractDirective>,
53}
54
55/// A directive the provider once wrote on a host and now withdraws.
56///
57/// A key the same host also lists in `directives` is written rather than
58/// withdrawn: the provider wants that value, so the rule is skipped.
59///
60/// The provider decides whether the value on disk is one of its own, because
61/// only it knows the shape it writes. A transport's command usually follows
62/// the vendor's published line, so its opening is exactly what a user who set
63/// the same thing up by hand would have typed; recognizing one by its opening
64/// would delete the other. A value the provider does not claim is left alone,
65/// so the worst case is a stale line the user can see and delete, never a line
66/// silently removed.
67#[derive(Debug, Clone)]
68pub struct RetractDirective {
69 /// The directive keyword, e.g. `ProxyCommand`.
70 pub key: String,
71 /// Whether `value` is one this provider wrote. `context` carries whatever
72 /// the decision needs, which for AWS is the region. A plain function
73 /// pointer rather than a closure, so `ProviderHost` stays `Clone`.
74 pub owns: fn(value: &str, context: &str) -> bool,
75 pub context: String,
76}
77
78impl RetractDirective {
79 /// Whether this rule claims the value currently on the host.
80 pub fn claims(&self, value: &str) -> bool {
81 (self.owns)(value, &self.context)
82 }
83}
84
85impl ProviderHost {
86 /// Create a ProviderHost with no metadata.
87 #[allow(dead_code)]
88 pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
89 Self {
90 server_id,
91 name,
92 ip,
93 tags,
94 ..Default::default()
95 }
96 }
97}
98
99/// Errors from provider API calls.
100#[derive(Debug, Error)]
101pub enum ProviderError {
102 #[error("HTTP error: {0}")]
103 Http(String),
104 #[error("Failed to parse response: {0}")]
105 Parse(String),
106 #[error("Authentication failed. Check your API token.")]
107 AuthFailed,
108 #[error("Rate limited. Try again in a moment.")]
109 RateLimited,
110 #[error("{0}")]
111 Execute(String),
112 #[error("Cancelled.")]
113 Cancelled,
114 /// Some hosts were fetched but others failed. The caller should use the
115 /// hosts but suppress destructive operations like --remove.
116 #[error("Partial result: {failures} of {total} failed")]
117 PartialResult {
118 hosts: Vec<ProviderHost>,
119 failures: usize,
120 total: usize,
121 },
122}
123
124/// Trait implemented by each cloud provider.
125///
126/// The trait deliberately stays narrow: every provider implements
127/// `fetch_hosts_cancellable` itself rather than overriding a uniform
128/// fetch loop. Variation is too wide for a single shape. Linode and
129/// DigitalOcean use `?page=N&per_page=N`; GCP uses `?maxResults=N`
130/// plus a `pageToken` cursor across an aggregated multi-zone listing;
131/// Azure paginates across multiple subscriptions; AWS uses SigV4 plus
132/// the EC2 query API; Tailscale mixes Basic and Bearer auth depending
133/// on key prefix; Oracle signs each request with a per-tenancy RSA
134/// key.
135///
136/// What is shared lives in this module as free helpers so every
137/// provider opts in to the parts that apply:
138/// - `bearer_auth`: `Authorization: Bearer <token>` formatter.
139/// - `ProviderMetadata`: typed builder for `ProviderHost::metadata`.
140/// - `paginate`: cancellable page-walk with `MAX_PAGES` guard.
141/// - `http_agent` / `http_agent_insecure`: ureq agent setup.
142/// - `map_ureq_error`: HTTP error to `ProviderError` mapping
143/// (401 to `AuthFailed`, 429 to `RateLimited`, etc).
144/// - `strip_cidr`, `percent_encode`, `epoch_to_date`: string and
145/// URL helpers.
146///
147/// Adding a new provider: implement `fetch_hosts_cancellable` and
148/// reach for the helpers above instead of reimplementing them. Do
149/// not invent provider-specific copies of these primitives; that is
150/// where past parity bugs have hidden.
151pub trait Provider {
152 /// Full provider name (e.g. "digitalocean").
153 fn name(&self) -> &str;
154 /// Short label for aliases (e.g. "do").
155 fn short_label(&self) -> &str;
156 /// Fetch hosts with cancellation support. `env` carries the resolved
157 /// process environment (home directory, credential env vars) so the few
158 /// providers that read AWS credentials or expand `~` in key paths take
159 /// them from the injected snapshot instead of ambient `std::env` /
160 /// `dirs::home_dir`. Most providers ignore it.
161 #[allow(dead_code)]
162 fn fetch_hosts_cancellable(
163 &self,
164 token: &str,
165 cancel: &AtomicBool,
166 env: &crate::runtime::env::Env,
167 ) -> Result<Vec<ProviderHost>, ProviderError>;
168 /// Fetch all servers from the provider API.
169 #[allow(dead_code)]
170 fn fetch_hosts(
171 &self,
172 token: &str,
173 env: &crate::runtime::env::Env,
174 ) -> Result<Vec<ProviderHost>, ProviderError> {
175 self.fetch_hosts_cancellable(token, &AtomicBool::new(false), env)
176 }
177 /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
178 #[allow(dead_code)]
179 fn fetch_hosts_with_progress(
180 &self,
181 token: &str,
182 cancel: &AtomicBool,
183 env: &crate::runtime::env::Env,
184 _progress: &dyn Fn(&str),
185 ) -> Result<Vec<ProviderHost>, ProviderError> {
186 self.fetch_hosts_cancellable(token, cancel, env)
187 }
188}
189
190/// Parse a comma-separated provider config field into a list of trimmed,
191/// non-empty entries. Used for regions/zones/subscriptions.
192fn parse_csv(s: &str) -> Vec<String> {
193 s.split(',')
194 .map(|s| s.trim().to_string())
195 .filter(|s| !s.is_empty())
196 .collect()
197}
198
199/// Format an `Authorization: Bearer <token>` header value. Centralised so
200/// the literal lives in one place across the 9-plus providers that use
201/// bearer auth; a typo in any of them would silently break sync.
202pub(crate) fn bearer_auth(token: &str) -> String {
203 format!("Bearer {token}")
204}
205
206/// Builder for `ProviderHost::metadata`. Replaces the
207/// `metadata.push(("region".to_string(), value.clone()))` boilerplate
208/// that repeated across every provider with a typed builder so callers
209/// only spell the value once and never clone strings by hand.
210///
211/// Conventions: keys are spelled as `&'static str` literals on each
212/// call so a typo is a compile error rather than a silent runtime
213/// mismatch downstream. The canonical key set (`region`, `status`,
214/// `plan`, `image`, `os`, `location`, `type`, `zone`, `size`, `instance`,
215/// `project`, `compartment`, `tenancy`) lives in the trait docs.
216#[derive(Debug, Default)]
217pub(crate) struct ProviderMetadata(Vec<(String, String)>);
218
219impl ProviderMetadata {
220 pub(crate) fn new() -> Self {
221 Self(Vec::new())
222 }
223
224 /// Append `(key, value)` and return the builder for chaining.
225 /// `value: impl Into<String>` accepts both `String` and `&str`, so
226 /// callers do not have to `.to_string()` their fields by hand.
227 pub(crate) fn push(&mut self, key: &'static str, value: impl Into<String>) -> &mut Self {
228 self.0.push((key.to_string(), value.into()));
229 self
230 }
231
232 /// Append `(key, value)` only when `value` is `Some`. Saves the
233 /// repeated `if let Some(v) = ... { metadata.push(...) }` wrapper.
234 pub(crate) fn push_opt<S: Into<String>>(
235 &mut self,
236 key: &'static str,
237 value: Option<S>,
238 ) -> &mut Self {
239 if let Some(v) = value {
240 self.0.push((key.to_string(), v.into()));
241 }
242 self
243 }
244
245 /// Consume the builder, yielding the canonical `Vec<(String,
246 /// String)>` shape that `ProviderHost::metadata` expects.
247 pub(crate) fn finish(self) -> Vec<(String, String)> {
248 self.0
249 }
250}
251
252/// Factory for a provider implementation from an optional config section.
253/// `None` yields a default-constructed instance; `Some(section)` wires the
254/// section's fields into the provider struct.
255type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
256
257/// Static registry entry describing one provider. Adding a provider means
258/// adding exactly one `ProviderDescriptor` to `PROVIDERS` below.
259pub struct ProviderDescriptor {
260 /// Slug used in config files and aliases.
261 pub name: &'static str,
262 /// Human-readable name shown in the UI.
263 pub display: &'static str,
264 /// Builder. Must not allocate or fail.
265 pub build: ProviderBuild,
266}
267
268/// Single source of truth for the provider registry. Adding a new provider
269/// means one entry here plus the provider module itself.
270pub const PROVIDERS: &[ProviderDescriptor] = &[
271 ProviderDescriptor {
272 name: "digitalocean",
273 display: "DigitalOcean",
274 build: |_| Box::new(digitalocean::DigitalOcean),
275 },
276 ProviderDescriptor {
277 name: "vultr",
278 display: "Vultr",
279 build: |_| Box::new(vultr::Vultr),
280 },
281 ProviderDescriptor {
282 name: "linode",
283 display: "Linode",
284 build: |_| Box::new(linode::Linode),
285 },
286 ProviderDescriptor {
287 name: "hetzner",
288 display: "Hetzner",
289 build: |_| Box::new(hetzner::Hetzner),
290 },
291 ProviderDescriptor {
292 name: "upcloud",
293 display: "UpCloud",
294 build: |_| Box::new(upcloud::UpCloud),
295 },
296 ProviderDescriptor {
297 name: "proxmox",
298 display: "Proxmox VE",
299 build: |section| {
300 let s = section.cloned().unwrap_or_default();
301 Box::new(proxmox::Proxmox {
302 base_url: s.url,
303 verify_tls: s.verify_tls,
304 })
305 },
306 },
307 ProviderDescriptor {
308 name: "aws",
309 display: "AWS EC2",
310 build: |section| {
311 let s = section.cloned().unwrap_or_default();
312 Box::new(aws::Aws {
313 regions: parse_csv(&s.regions),
314 profile: s.profile,
315 ssm: s.ssm,
316 })
317 },
318 },
319 ProviderDescriptor {
320 name: "scaleway",
321 display: "Scaleway",
322 build: |section| {
323 let s = section.cloned().unwrap_or_default();
324 Box::new(scaleway::Scaleway {
325 zones: parse_csv(&s.regions),
326 })
327 },
328 },
329 ProviderDescriptor {
330 name: "gcp",
331 display: "GCP",
332 build: |section| {
333 let s = section.cloned().unwrap_or_default();
334 Box::new(gcp::Gcp {
335 zones: parse_csv(&s.regions),
336 project: s.project,
337 })
338 },
339 },
340 ProviderDescriptor {
341 name: "azure",
342 display: "Azure",
343 build: |section| {
344 let s = section.cloned().unwrap_or_default();
345 Box::new(azure::Azure {
346 subscriptions: parse_csv(&s.regions),
347 })
348 },
349 },
350 ProviderDescriptor {
351 name: "tailscale",
352 display: "Tailscale",
353 build: |_| Box::new(tailscale::Tailscale),
354 },
355 ProviderDescriptor {
356 name: "oracle",
357 display: "Oracle Cloud",
358 build: |section| {
359 let s = section.cloned().unwrap_or_default();
360 Box::new(oracle::Oracle {
361 regions: parse_csv(&s.regions),
362 compartment: s.compartment,
363 })
364 },
365 },
366 ProviderDescriptor {
367 name: "ovh",
368 display: "OVHcloud",
369 // OVH overloads `regions` as the API endpoint (e.g. "ovh-eu").
370 // Known quirk flagged in the architecture review; kept as-is to
371 // avoid schema migration in this refactor.
372 build: |section| {
373 let s = section.cloned().unwrap_or_default();
374 Box::new(ovh::Ovh {
375 project: s.project,
376 endpoint: s.regions,
377 })
378 },
379 },
380 ProviderDescriptor {
381 name: "leaseweb",
382 display: "Leaseweb",
383 build: |_| Box::new(leaseweb::Leaseweb),
384 },
385 ProviderDescriptor {
386 name: "i3d",
387 display: "i3D.net",
388 build: |_| Box::new(i3d::I3d),
389 },
390 ProviderDescriptor {
391 name: "transip",
392 display: "TransIP",
393 build: |_| Box::new(transip::TransIp),
394 },
395 ProviderDescriptor {
396 name: "teleport",
397 display: "Teleport",
398 build: |section| {
399 let s = section.cloned().unwrap_or_default();
400 Box::new(teleport::Teleport {
401 identity_file: s.identity_file,
402 })
403 },
404 },
405 ProviderDescriptor {
406 name: "netbox",
407 display: "NetBox",
408 build: |section| {
409 let s = section.cloned().unwrap_or_default();
410 Box::new(netbox::NetBox {
411 base_url: s.url,
412 verify_tls: s.verify_tls,
413 filter: s.filter,
414 })
415 },
416 },
417];
418
419/// Look up a descriptor by bare provider name. Internal helper; public wrappers
420/// below strip any `:label` suffix before calling this, so callers cannot
421/// accidentally pass a labeled id (`proxmox:server1`) and silently miss.
422fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
423 PROVIDERS.iter().find(|p| p.name == name)
424}
425
426/// Return the bare provider name, stripping an optional `:label` suffix.
427/// `ProviderConfigId` is the canonical home for this split; this helper keeps
428/// `&str`-only public APIs label-tolerant without forcing every caller to
429/// parse first.
430fn bare_provider_name(name: &str) -> &str {
431 name.split_once(':').map(|(p, _)| p).unwrap_or(name)
432}
433
434/// All known provider names, in registration order.
435pub const PROVIDER_NAMES: &[&str] = &[
436 "digitalocean",
437 "vultr",
438 "linode",
439 "hetzner",
440 "upcloud",
441 "proxmox",
442 "aws",
443 "scaleway",
444 "gcp",
445 "azure",
446 "tailscale",
447 "oracle",
448 "ovh",
449 "leaseweb",
450 "i3d",
451 "transip",
452 "teleport",
453 "netbox",
454];
455
456// Compile-time guard: PROVIDER_NAMES and PROVIDERS must stay in lockstep.
457const _: () = {
458 assert!(
459 PROVIDER_NAMES.len() == PROVIDERS.len(),
460 "PROVIDER_NAMES and PROVIDERS length must match",
461 );
462};
463
464/// Get a provider implementation by name with default configuration. Accepts
465/// either a bare provider name (`"proxmox"`) or a labeled id (`"proxmox:server1"`).
466pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
467 descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
468}
469
470/// Get a provider implementation configured from a provider section. The bare
471/// provider name comes from `section.id.provider`, so labeled configs resolve
472/// the right descriptor by construction; passing a separate `name` was
473/// historically a foot-gun (issue #51) where callers handed in the labeled id
474/// string and the lookup missed the registry.
475pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
476 descriptor(section.provider()).map(|d| (d.build)(Some(section)))
477}
478
479/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean"). Accepts
480/// either a bare name or a labeled id; unknown names fall back to the input.
481pub fn provider_display_name(name: &str) -> &str {
482 descriptor(bare_provider_name(name))
483 .map(|d| d.display)
484 .unwrap_or(name)
485}
486
487/// Create an HTTP agent with explicit timeouts.
488pub(crate) fn http_agent() -> ureq::Agent {
489 ureq::Agent::config_builder()
490 .timeout_global(Some(std::time::Duration::from_secs(30)))
491 .max_redirects(0)
492 .build()
493 .new_agent()
494}
495
496/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
497pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
498 Ok(ureq::Agent::config_builder()
499 .timeout_global(Some(std::time::Duration::from_secs(30)))
500 .max_redirects(0)
501 .tls_config(
502 ureq::tls::TlsConfig::builder()
503 .provider(ureq::tls::TlsProvider::NativeTls)
504 .disable_verification(true)
505 .build(),
506 )
507 .build()
508 .new_agent())
509}
510
511/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
512/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
513/// SSH requires bare addresses without CIDR notation.
514pub(crate) fn strip_cidr(ip: &str) -> &str {
515 // Only strip if it looks like a CIDR suffix (slash followed by digits)
516 if let Some(pos) = ip.rfind('/')
517 && ip[pos + 1..].bytes().all(|b| b.is_ascii_digit())
518 && pos + 1 < ip.len()
519 {
520 return &ip[..pos];
521 }
522 ip
523}
524
525/// RFC 3986 percent-encoding for URL query parameters.
526/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
527pub(crate) fn percent_encode(s: &str) -> String {
528 let mut result = String::with_capacity(s.len());
529 for byte in s.bytes() {
530 match byte {
531 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
532 result.push(byte as char);
533 }
534 _ => {
535 result.push_str(&format!("%{:02X}", byte));
536 }
537 }
538 }
539 result
540}
541
542/// Date components from a Unix epoch timestamp (no chrono dependency).
543pub(crate) struct EpochDate {
544 pub year: u64,
545 pub month: u64, // 1-based
546 pub day: u64, // 1-based
547 pub hours: u64,
548 pub minutes: u64,
549 pub seconds: u64,
550 /// Days since epoch (for weekday calculation)
551 pub epoch_days: u64,
552}
553
554/// Convert Unix epoch seconds to date components.
555pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
556 let secs_per_day = 86400u64;
557 let epoch_days = epoch_secs / secs_per_day;
558 let mut remaining_days = epoch_days;
559 let day_secs = epoch_secs % secs_per_day;
560
561 let mut year = 1970u64;
562 loop {
563 let leap =
564 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
565 let days_in_year = if leap { 366 } else { 365 };
566 if remaining_days < days_in_year {
567 break;
568 }
569 remaining_days -= days_in_year;
570 year += 1;
571 }
572
573 let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
574 let days_per_month: [u64; 12] = [
575 31,
576 if leap { 29 } else { 28 },
577 31,
578 30,
579 31,
580 30,
581 31,
582 31,
583 30,
584 31,
585 30,
586 31,
587 ];
588 let mut month = 0usize;
589 while month < 12 && remaining_days >= days_per_month[month] {
590 remaining_days -= days_per_month[month];
591 month += 1;
592 }
593
594 EpochDate {
595 year,
596 month: (month + 1) as u64,
597 day: remaining_days + 1,
598 hours: day_secs / 3600,
599 minutes: (day_secs % 3600) / 60,
600 seconds: day_secs % 60,
601 epoch_days,
602 }
603}
604
605/// The one status a caller reads rather than reports. A provider that takes
606/// the status as data instead of letting ureq raise it still has to map this
607/// one, because back-off depends on it.
608pub(crate) const HTTP_TOO_MANY_REQUESTS: u16 = 429;
609
610/// Map a ureq error to a ProviderError.
611fn map_ureq_error(err: ureq::Error) -> ProviderError {
612 match err {
613 ureq::Error::StatusCode(code) => match code {
614 401 | 403 => {
615 error!("[external] HTTP {code}: authentication failed");
616 ProviderError::AuthFailed
617 }
618 HTTP_TOO_MANY_REQUESTS => {
619 warn!("[external] HTTP 429: rate limited");
620 ProviderError::RateLimited
621 }
622 _ => {
623 error!("[external] HTTP {code}");
624 ProviderError::Http(format!("HTTP {}", code))
625 }
626 },
627 other => {
628 error!("[external] Request failed: {other}");
629 ProviderError::Http(other.to_string())
630 }
631 }
632}
633
634/// Upper bound on pages fetched from one paginated list endpoint. A safety
635/// valve for a provider that never signals its last page; 500 pages covers any
636/// realistic account.
637pub(crate) const MAX_PAGES: u64 = 500;
638
639/// One mapped page from a paginated list endpoint.
640pub(crate) struct PageResult {
641 /// Hosts mapped from this page, appended to the running total.
642 pub hosts: Vec<ProviderHost>,
643 /// Whether another page should be fetched.
644 pub more: bool,
645}
646
647/// Drive a paginated list endpoint under one shared cancellation, runaway-guard
648/// and partial-failure contract, so every JSON list provider behaves the same.
649///
650/// `fetch_page(index)` performs one request (0-based page index), parses it and
651/// maps entries into a `PageResult`. The closure owns its own cursor or
652/// page-number state across calls.
653///
654/// Failure policy, matching what `sync` relies on: a failure on the first page
655/// (nothing collected) propagates as a hard error so the provider is skipped
656/// and the config left untouched. A failure on a later page returns the hosts
657/// gathered so far as `PartialResult`, so add and update still run while remove
658/// and stale marking are suppressed upstream. `AuthFailed`, `RateLimited` and
659/// `Cancelled` always propagate immediately, even mid-run, because they
660/// invalidate the whole sync.
661pub(crate) fn paginate<F>(
662 cancel: &AtomicBool,
663 mut fetch_page: F,
664) -> Result<Vec<ProviderHost>, ProviderError>
665where
666 F: FnMut(u64) -> Result<PageResult, ProviderError>,
667{
668 let mut hosts = Vec::new();
669 let mut index = 0u64;
670 loop {
671 if cancel.load(Ordering::Relaxed) {
672 return Err(ProviderError::Cancelled);
673 }
674 match fetch_page(index) {
675 Ok(page) => {
676 hosts.extend(page.hosts);
677 if !page.more {
678 return Ok(hosts);
679 }
680 }
681 Err(
682 e @ (ProviderError::Cancelled
683 | ProviderError::AuthFailed
684 | ProviderError::RateLimited),
685 ) => return Err(e),
686 Err(e) => {
687 if hosts.is_empty() {
688 return Err(e);
689 }
690 debug!(
691 "[external] paginate: page {} failed after {} hosts collected, returning partial result ({e})",
692 index + 1,
693 hosts.len()
694 );
695 return Err(ProviderError::PartialResult {
696 hosts,
697 failures: 1,
698 total: (index + 1) as usize,
699 });
700 }
701 }
702 index += 1;
703 if index >= MAX_PAGES {
704 debug!(
705 "[purple] paginate: reached MAX_PAGES ({MAX_PAGES}) guard, stopping with {} hosts",
706 hosts.len()
707 );
708 return Ok(hosts);
709 }
710 }
711}
712
713#[cfg(test)]
714#[path = "mod_tests.rs"]
715mod tests;