Skip to main content

purple_ssh/providers/
mod.rs

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