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#[derive(Debug, Clone)]
30pub struct ProviderHost {
31 pub server_id: String,
33 pub name: String,
35 pub ip: String,
37 pub tags: Vec<String>,
39 pub metadata: Vec<(String, String)>,
41}
42
43impl ProviderHost {
44 #[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#[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 #[error("Partial result: {failures} of {total} failed")]
75 PartialResult {
76 hosts: Vec<ProviderHost>,
77 failures: usize,
78 total: usize,
79 },
80}
81
82pub trait Provider {
110 fn name(&self) -> &str;
112 fn short_label(&self) -> &str;
114 #[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 #[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 #[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
148fn 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
157pub(crate) fn bearer_auth(token: &str) -> String {
161 format!("Bearer {token}")
162}
163
164#[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 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 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 pub(crate) fn finish(self) -> Vec<(String, String)> {
206 self.0
207 }
208}
209
210type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
214
215pub struct ProviderDescriptor {
218 pub name: &'static str,
220 pub display: &'static str,
222 pub build: ProviderBuild,
224}
225
226pub 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 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
354fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
358 PROVIDERS.iter().find(|p| p.name == name)
359}
360
361fn bare_provider_name(name: &str) -> &str {
366 name.split_once(':').map(|(p, _)| p).unwrap_or(name)
367}
368
369pub 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
389const _: () = {
391 assert!(
392 PROVIDER_NAMES.len() == PROVIDERS.len(),
393 "PROVIDER_NAMES and PROVIDERS length must match",
394 );
395};
396
397pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
400 descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
401}
402
403pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
409 descriptor(section.provider()).map(|d| (d.build)(Some(section)))
410}
411
412pub fn provider_display_name(name: &str) -> &str {
415 descriptor(bare_provider_name(name))
416 .map(|d| d.display)
417 .unwrap_or(name)
418}
419
420pub(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
429pub(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
444pub(crate) fn strip_cidr(ip: &str) -> &str {
448 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
457pub(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
474pub(crate) struct EpochDate {
476 pub year: u64,
477 pub month: u64, pub day: u64, pub hours: u64,
480 pub minutes: u64,
481 pub seconds: u64,
482 pub epoch_days: u64,
484}
485
486pub(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
536fn 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
560pub(crate) const MAX_PAGES: u64 = 500;
564
565pub(crate) struct PageResult {
567 pub hosts: Vec<ProviderHost>,
569 pub more: bool,
571}
572
573pub(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;