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