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 teleport;
18mod transip;
19mod upcloud;
20mod vultr;
21
22pub use kind::ProviderKind;
23
24use std::sync::atomic::{AtomicBool, Ordering};
25
26use log::{debug, error, warn};
27use thiserror::Error;
28
29#[derive(Debug, Clone, Default)]
31pub struct ProviderHost {
32 pub server_id: String,
34 pub name: String,
36 pub ip: String,
38 pub tags: Vec<String>,
40 pub metadata: Vec<(String, String)>,
42 pub port: Option<u16>,
44 pub directives: Vec<(String, String)>,
47}
48
49impl ProviderHost {
50 #[allow(dead_code)]
52 pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
53 Self {
54 server_id,
55 name,
56 ip,
57 tags,
58 ..Default::default()
59 }
60 }
61}
62
63#[derive(Debug, Error)]
65pub enum ProviderError {
66 #[error("HTTP error: {0}")]
67 Http(String),
68 #[error("Failed to parse response: {0}")]
69 Parse(String),
70 #[error("Authentication failed. Check your API token.")]
71 AuthFailed,
72 #[error("Rate limited. Try again in a moment.")]
73 RateLimited,
74 #[error("{0}")]
75 Execute(String),
76 #[error("Cancelled.")]
77 Cancelled,
78 #[error("Partial result: {failures} of {total} failed")]
81 PartialResult {
82 hosts: Vec<ProviderHost>,
83 failures: usize,
84 total: usize,
85 },
86}
87
88pub trait Provider {
116 fn name(&self) -> &str;
118 fn short_label(&self) -> &str;
120 #[allow(dead_code)]
126 fn fetch_hosts_cancellable(
127 &self,
128 token: &str,
129 cancel: &AtomicBool,
130 env: &crate::runtime::env::Env,
131 ) -> Result<Vec<ProviderHost>, ProviderError>;
132 #[allow(dead_code)]
134 fn fetch_hosts(
135 &self,
136 token: &str,
137 env: &crate::runtime::env::Env,
138 ) -> Result<Vec<ProviderHost>, ProviderError> {
139 self.fetch_hosts_cancellable(token, &AtomicBool::new(false), env)
140 }
141 #[allow(dead_code)]
143 fn fetch_hosts_with_progress(
144 &self,
145 token: &str,
146 cancel: &AtomicBool,
147 env: &crate::runtime::env::Env,
148 _progress: &dyn Fn(&str),
149 ) -> Result<Vec<ProviderHost>, ProviderError> {
150 self.fetch_hosts_cancellable(token, cancel, env)
151 }
152}
153
154fn parse_csv(s: &str) -> Vec<String> {
157 s.split(',')
158 .map(|s| s.trim().to_string())
159 .filter(|s| !s.is_empty())
160 .collect()
161}
162
163pub(crate) fn bearer_auth(token: &str) -> String {
167 format!("Bearer {token}")
168}
169
170#[derive(Debug, Default)]
181pub(crate) struct ProviderMetadata(Vec<(String, String)>);
182
183impl ProviderMetadata {
184 pub(crate) fn new() -> Self {
185 Self(Vec::new())
186 }
187
188 pub(crate) fn push(&mut self, key: &'static str, value: impl Into<String>) -> &mut Self {
192 self.0.push((key.to_string(), value.into()));
193 self
194 }
195
196 pub(crate) fn push_opt<S: Into<String>>(
199 &mut self,
200 key: &'static str,
201 value: Option<S>,
202 ) -> &mut Self {
203 if let Some(v) = value {
204 self.0.push((key.to_string(), v.into()));
205 }
206 self
207 }
208
209 pub(crate) fn finish(self) -> Vec<(String, String)> {
212 self.0
213 }
214}
215
216type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
220
221pub struct ProviderDescriptor {
224 pub name: &'static str,
226 pub display: &'static str,
228 pub build: ProviderBuild,
230}
231
232pub const PROVIDERS: &[ProviderDescriptor] = &[
235 ProviderDescriptor {
236 name: "digitalocean",
237 display: "DigitalOcean",
238 build: |_| Box::new(digitalocean::DigitalOcean),
239 },
240 ProviderDescriptor {
241 name: "vultr",
242 display: "Vultr",
243 build: |_| Box::new(vultr::Vultr),
244 },
245 ProviderDescriptor {
246 name: "linode",
247 display: "Linode",
248 build: |_| Box::new(linode::Linode),
249 },
250 ProviderDescriptor {
251 name: "hetzner",
252 display: "Hetzner",
253 build: |_| Box::new(hetzner::Hetzner),
254 },
255 ProviderDescriptor {
256 name: "upcloud",
257 display: "UpCloud",
258 build: |_| Box::new(upcloud::UpCloud),
259 },
260 ProviderDescriptor {
261 name: "proxmox",
262 display: "Proxmox VE",
263 build: |section| {
264 let s = section.cloned().unwrap_or_default();
265 Box::new(proxmox::Proxmox {
266 base_url: s.url,
267 verify_tls: s.verify_tls,
268 })
269 },
270 },
271 ProviderDescriptor {
272 name: "aws",
273 display: "AWS EC2",
274 build: |section| {
275 let s = section.cloned().unwrap_or_default();
276 Box::new(aws::Aws {
277 regions: parse_csv(&s.regions),
278 profile: s.profile,
279 })
280 },
281 },
282 ProviderDescriptor {
283 name: "scaleway",
284 display: "Scaleway",
285 build: |section| {
286 let s = section.cloned().unwrap_or_default();
287 Box::new(scaleway::Scaleway {
288 zones: parse_csv(&s.regions),
289 })
290 },
291 },
292 ProviderDescriptor {
293 name: "gcp",
294 display: "GCP",
295 build: |section| {
296 let s = section.cloned().unwrap_or_default();
297 Box::new(gcp::Gcp {
298 zones: parse_csv(&s.regions),
299 project: s.project,
300 })
301 },
302 },
303 ProviderDescriptor {
304 name: "azure",
305 display: "Azure",
306 build: |section| {
307 let s = section.cloned().unwrap_or_default();
308 Box::new(azure::Azure {
309 subscriptions: parse_csv(&s.regions),
310 })
311 },
312 },
313 ProviderDescriptor {
314 name: "tailscale",
315 display: "Tailscale",
316 build: |_| Box::new(tailscale::Tailscale),
317 },
318 ProviderDescriptor {
319 name: "oracle",
320 display: "Oracle Cloud",
321 build: |section| {
322 let s = section.cloned().unwrap_or_default();
323 Box::new(oracle::Oracle {
324 regions: parse_csv(&s.regions),
325 compartment: s.compartment,
326 })
327 },
328 },
329 ProviderDescriptor {
330 name: "ovh",
331 display: "OVHcloud",
332 build: |section| {
336 let s = section.cloned().unwrap_or_default();
337 Box::new(ovh::Ovh {
338 project: s.project,
339 endpoint: s.regions,
340 })
341 },
342 },
343 ProviderDescriptor {
344 name: "leaseweb",
345 display: "Leaseweb",
346 build: |_| Box::new(leaseweb::Leaseweb),
347 },
348 ProviderDescriptor {
349 name: "i3d",
350 display: "i3D.net",
351 build: |_| Box::new(i3d::I3d),
352 },
353 ProviderDescriptor {
354 name: "transip",
355 display: "TransIP",
356 build: |_| Box::new(transip::TransIp),
357 },
358 ProviderDescriptor {
359 name: "teleport",
360 display: "Teleport",
361 build: |section| {
362 let s = section.cloned().unwrap_or_default();
363 Box::new(teleport::Teleport {
364 identity_file: s.identity_file,
365 })
366 },
367 },
368];
369
370fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
374 PROVIDERS.iter().find(|p| p.name == name)
375}
376
377fn bare_provider_name(name: &str) -> &str {
382 name.split_once(':').map(|(p, _)| p).unwrap_or(name)
383}
384
385pub const PROVIDER_NAMES: &[&str] = &[
387 "digitalocean",
388 "vultr",
389 "linode",
390 "hetzner",
391 "upcloud",
392 "proxmox",
393 "aws",
394 "scaleway",
395 "gcp",
396 "azure",
397 "tailscale",
398 "oracle",
399 "ovh",
400 "leaseweb",
401 "i3d",
402 "transip",
403 "teleport",
404];
405
406const _: () = {
408 assert!(
409 PROVIDER_NAMES.len() == PROVIDERS.len(),
410 "PROVIDER_NAMES and PROVIDERS length must match",
411 );
412};
413
414pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
417 descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
418}
419
420pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
426 descriptor(section.provider()).map(|d| (d.build)(Some(section)))
427}
428
429pub fn provider_display_name(name: &str) -> &str {
432 descriptor(bare_provider_name(name))
433 .map(|d| d.display)
434 .unwrap_or(name)
435}
436
437pub(crate) fn http_agent() -> ureq::Agent {
439 ureq::Agent::config_builder()
440 .timeout_global(Some(std::time::Duration::from_secs(30)))
441 .max_redirects(0)
442 .build()
443 .new_agent()
444}
445
446pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
448 Ok(ureq::Agent::config_builder()
449 .timeout_global(Some(std::time::Duration::from_secs(30)))
450 .max_redirects(0)
451 .tls_config(
452 ureq::tls::TlsConfig::builder()
453 .provider(ureq::tls::TlsProvider::NativeTls)
454 .disable_verification(true)
455 .build(),
456 )
457 .build()
458 .new_agent())
459}
460
461pub(crate) fn strip_cidr(ip: &str) -> &str {
465 if let Some(pos) = ip.rfind('/') {
467 if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
468 return &ip[..pos];
469 }
470 }
471 ip
472}
473
474pub(crate) fn percent_encode(s: &str) -> String {
477 let mut result = String::with_capacity(s.len());
478 for byte in s.bytes() {
479 match byte {
480 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
481 result.push(byte as char);
482 }
483 _ => {
484 result.push_str(&format!("%{:02X}", byte));
485 }
486 }
487 }
488 result
489}
490
491pub(crate) struct EpochDate {
493 pub year: u64,
494 pub month: u64, pub day: u64, pub hours: u64,
497 pub minutes: u64,
498 pub seconds: u64,
499 pub epoch_days: u64,
501}
502
503pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
505 let secs_per_day = 86400u64;
506 let epoch_days = epoch_secs / secs_per_day;
507 let mut remaining_days = epoch_days;
508 let day_secs = epoch_secs % secs_per_day;
509
510 let mut year = 1970u64;
511 loop {
512 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
513 let days_in_year = if leap { 366 } else { 365 };
514 if remaining_days < days_in_year {
515 break;
516 }
517 remaining_days -= days_in_year;
518 year += 1;
519 }
520
521 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
522 let days_per_month: [u64; 12] = [
523 31,
524 if leap { 29 } else { 28 },
525 31,
526 30,
527 31,
528 30,
529 31,
530 31,
531 30,
532 31,
533 30,
534 31,
535 ];
536 let mut month = 0usize;
537 while month < 12 && remaining_days >= days_per_month[month] {
538 remaining_days -= days_per_month[month];
539 month += 1;
540 }
541
542 EpochDate {
543 year,
544 month: (month + 1) as u64,
545 day: remaining_days + 1,
546 hours: day_secs / 3600,
547 minutes: (day_secs % 3600) / 60,
548 seconds: day_secs % 60,
549 epoch_days,
550 }
551}
552
553fn map_ureq_error(err: ureq::Error) -> ProviderError {
555 match err {
556 ureq::Error::StatusCode(code) => match code {
557 401 | 403 => {
558 error!("[external] HTTP {code}: authentication failed");
559 ProviderError::AuthFailed
560 }
561 429 => {
562 warn!("[external] HTTP 429: rate limited");
563 ProviderError::RateLimited
564 }
565 _ => {
566 error!("[external] HTTP {code}");
567 ProviderError::Http(format!("HTTP {}", code))
568 }
569 },
570 other => {
571 error!("[external] Request failed: {other}");
572 ProviderError::Http(other.to_string())
573 }
574 }
575}
576
577pub(crate) const MAX_PAGES: u64 = 500;
581
582pub(crate) struct PageResult {
584 pub hosts: Vec<ProviderHost>,
586 pub more: bool,
588}
589
590pub(crate) fn paginate<F>(
605 cancel: &AtomicBool,
606 mut fetch_page: F,
607) -> Result<Vec<ProviderHost>, ProviderError>
608where
609 F: FnMut(u64) -> Result<PageResult, ProviderError>,
610{
611 let mut hosts = Vec::new();
612 let mut index = 0u64;
613 loop {
614 if cancel.load(Ordering::Relaxed) {
615 return Err(ProviderError::Cancelled);
616 }
617 match fetch_page(index) {
618 Ok(page) => {
619 hosts.extend(page.hosts);
620 if !page.more {
621 return Ok(hosts);
622 }
623 }
624 Err(
625 e @ (ProviderError::Cancelled
626 | ProviderError::AuthFailed
627 | ProviderError::RateLimited),
628 ) => return Err(e),
629 Err(e) => {
630 if hosts.is_empty() {
631 return Err(e);
632 }
633 debug!(
634 "[external] paginate: page {} failed after {} hosts collected, returning partial result ({e})",
635 index + 1,
636 hosts.len()
637 );
638 return Err(ProviderError::PartialResult {
639 hosts,
640 failures: 1,
641 total: (index + 1) as usize,
642 });
643 }
644 }
645 index += 1;
646 if index >= MAX_PAGES {
647 debug!(
648 "[purple] paginate: reached MAX_PAGES ({MAX_PAGES}) guard, stopping with {} hosts",
649 hosts.len()
650 );
651 return Ok(hosts);
652 }
653 }
654}
655
656#[cfg(test)]
657#[path = "mod_tests.rs"]
658mod tests;