1use std::path::Path;
49
50use serde_json::Value;
51
52pub mod guard;
53#[cfg(feature = "wire")]
54pub mod mock_door;
55#[cfg(feature = "wire")]
56pub mod net;
57pub mod over;
58#[cfg(feature = "wire")]
59mod wire;
60
61pub use over::{Call, Exchange, Method, Over};
62#[cfg(feature = "wire")]
63pub use wire::{connect, Credential, Options};
64
65const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
69
70pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
73
74pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
77
78pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
81
82pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
84
85#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Endpoint {
91 Account,
93 Mock(MockBase),
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct MockBase(String);
102
103impl MockBase {
104 pub fn as_str(&self) -> &str {
105 &self.0
106 }
107}
108
109impl Endpoint {
110 pub fn account() -> Result<Endpoint, String> {
115 Endpoint::account_given(|k| std::env::var(k).ok())
116 }
117
118 pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
120 let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
121 if set.is_empty() {
122 return Ok(Endpoint::Account);
123 }
124 Err(format!(
125 "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
126 a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
127 process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
128 <loopback base>`), or unset {s} to use the account.",
129 s = set.join(" and ")
130 ))
131 }
132
133 pub fn mock(base: &str) -> Result<Endpoint, String> {
137 let mut b = base.trim().trim_end_matches('/').to_string();
138 if !is_loopback(&b) {
139 return Err(format!(
140 "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
141 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
142 machine can be one."
143 ));
144 }
145 if !b.ends_with("/1.3") {
146 b.push_str("/1.3");
147 }
148 Ok(Endpoint::Mock(MockBase(b)))
149 }
150
151 pub fn mock_from_env() -> Result<Endpoint, String> {
154 let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
155 format!(
156 "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
157 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
158 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
159 )
160 })?;
161 Endpoint::mock(&raw)
162 }
163
164 pub fn is_account(&self) -> bool {
165 matches!(self, Endpoint::Account)
166 }
167
168 pub fn base_for_display(&self) -> &str {
171 match self {
172 Endpoint::Account => ACCOUNT_BASE,
173 Endpoint::Mock(b) => b.as_str(),
174 }
175 }
176
177 pub fn banner(&self) -> String {
180 match self {
181 Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
182 Endpoint::Mock(b) => format!(
183 "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
184 nothing measured here says anything about the account.",
185 b.as_str()
186 ),
187 }
188 }
189
190 pub fn child_args(&self) -> Vec<String> {
196 match self {
197 Endpoint::Account => Vec::new(),
198 Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
199 }
200 }
201}
202
203pub const MOCK_API_FLAG: &str = "--mock-api";
205
206impl Endpoint {
207 pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
211 match mock_api {
212 Some(b) => Endpoint::mock(b),
213 None => Endpoint::account(),
214 }
215 }
216}
217
218pub fn is_loopback(url: &str) -> bool {
221 let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
222 host == "127.0.0.1" || host == "localhost"
223}
224
225#[derive(Debug, Clone)]
232pub struct Reply {
233 pub status: u16,
234 pub body: Value,
236 pub text: String,
240}
241
242impl Reply {
243 pub fn ok(&self) -> bool {
244 (200..300).contains(&self.status)
245 }
246
247 pub fn error_code(&self) -> &str {
249 self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
250 }
251
252 pub fn error_message(&self) -> &str {
254 self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
255 }
256
257 pub fn describe_failure(&self, what: &str) -> String {
260 let code = self.error_code();
261 if code.is_empty() {
262 format!("{what} answered {} — {}", self.status, self.error_message())
263 } else {
264 format!("{what} answered {} {code} — {}", self.status, self.error_message())
265 }
266 }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum Stop {
275 Soft { timeout_s: u32 },
276 Hard,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum WithStorages {
283 AndTheirBackups,
286 AndKeepBackups,
289 LeaveThem,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum Backups {
296 Unsaid,
299 Keep,
301 Delete,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum DeviceKind {
308 Cdrom,
310 Disk,
312}
313
314impl DeviceKind {
315 pub fn as_str(self) -> &'static str {
316 match self {
317 DeviceKind::Cdrom => "cdrom",
318 DeviceKind::Disk => "disk",
319 }
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum BootOrder {
327 Cdrom,
328 Disk,
329}
330
331impl BootOrder {
332 pub fn as_str(self) -> &'static str {
333 match self {
334 BootOrder::Cdrom => "cdrom",
335 BootOrder::Disk => "disk",
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum Console<'a> {
343 Off,
344 Vnc { password: &'a str },
345}
346
347pub type Label<'a> = (&'a str, &'a str);
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct NewStorage<'a> {
354 pub title: &'a str,
355 pub zone: &'a str,
356 pub size_gib: u64,
357 pub tier: &'a str,
359 pub labels: &'a [Label<'a>],
361}
362
363pub trait UpCloudApi {
368 fn describe(&self) -> String;
371
372 fn is_the_account(&self) -> bool;
375
376 fn account(&self) -> Result<Reply, String>;
380 fn price(&self) -> Result<Reply, String>;
382 fn servers(&self) -> Result<Reply, String>;
385 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
388 fn server(&self, uuid: &str) -> Result<Reply, String>;
390 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
392 fn storages_private(&self) -> Result<Reply, String>;
395 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
398 fn storage(&self, uuid: &str) -> Result<Reply, String>;
401 fn zones(&self) -> Result<Reply, String>;
403 fn plans(&self) -> Result<Reply, String>;
405
406 fn create_server(&self, document: &Value) -> Result<Reply, String>;
411 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
413 fn start_server(&self, uuid: &str) -> Result<Reply, String>;
415 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
418 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
420 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
423 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
427 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
431 fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
434 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
436
437 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
441 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
443 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
447 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
453 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
456 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
459 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
461}
462
463macro_rules! forward {
466 ($($ty:tt)*) => {
467 impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
468 fn describe(&self) -> String { (**self).describe() }
469 fn is_the_account(&self) -> bool { (**self).is_the_account() }
470 fn account(&self) -> Result<Reply, String> { (**self).account() }
471 fn price(&self) -> Result<Reply, String> { (**self).price() }
472 fn servers(&self) -> Result<Reply, String> { (**self).servers() }
473 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
474 fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
475 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
476 fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
477 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
478 fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
479 fn zones(&self) -> Result<Reply, String> { (**self).zones() }
480 fn plans(&self) -> Result<Reply, String> { (**self).plans() }
481 fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
482 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
483 fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
484 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
485 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
486 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
487 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
488 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
489 fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
490 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
491 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
492 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
493 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
494 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
495 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
496 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
497 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
498 }
499 };
500}
501forward!(&T);
502forward!(Box<T>);
503
504pub fn delete_server_query(with: WithStorages) -> &'static str {
507 match with {
508 WithStorages::AndTheirBackups => "?storages=1&backups=delete",
509 WithStorages::AndKeepBackups => "?storages=1&backups=keep",
510 WithStorages::LeaveThem => "",
511 }
512}
513
514pub fn delete_storage_query(backups: Backups) -> &'static str {
516 match backups {
517 Backups::Unsaid => "",
518 Backups::Keep => "?backups=keep",
519 Backups::Delete => "?backups=delete",
520 }
521}
522
523pub fn label_query(labels: &[Label<'_>]) -> String {
526 fn enc(s: &str, out: &mut String) {
527 for b in s.bytes() {
528 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
529 out.push(b as char);
530 } else {
531 out.push_str(&format!("%{b:02X}"));
532 }
533 }
534 }
535 let mut q = String::new();
536 for (k, v) in labels {
537 q.push(if q.is_empty() { '?' } else { '&' });
538 q.push_str("label=");
539 enc(&format!("{k}={v}"), &mut q);
540 }
541 q
542}
543
544pub mod body {
549 use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
550 use serde_json::{json, Value};
551
552 pub fn stop(stop: Stop) -> Value {
555 match stop {
556 Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
557 Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
558 }
559 }
560 pub fn storage_size(gb: u64) -> Value {
561 json!({"storage": {"size": gb.to_string()}})
562 }
563 pub fn server_plan(plan: &str) -> Value {
564 json!({"server": {"plan": plan}})
565 }
566 pub fn boot_order(order: BootOrder) -> Value {
567 json!({"server": {"boot_order": order.as_str()}})
568 }
569 pub fn console(c: &Console<'_>) -> Value {
570 match c {
571 Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
572 Console::Vnc { password } => json!({"server": {
573 "remote_access_enabled": "yes",
574 "remote_access_type": "vnc",
575 "remote_access_password": password,
576 }}),
577 }
578 }
579 pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
580 match at {
581 None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
582 Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
583 }
584 }
585 pub fn detach(address: &str) -> Value {
587 json!({"storage_device": {"address": address}})
588 }
589 pub fn create_storage(n: &NewStorage<'_>) -> Value {
590 let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
591 if !n.labels.is_empty() {
592 v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
593 }
594 v
595 }
596 pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
598 json!({"storage": {"tier": tier, "title": title, "zone": zone}})
599 }
600 pub fn direct_upload() -> Value {
601 json!({"storage_import": {"source": "direct_upload"}})
602 }
603}
604
605pub fn redact_upload_url(url: &str) -> String {
609 match url.find("/session/") {
610 Some(i) => format!("{}/session/…", &url[..i]),
611 None => match url.rfind('/') {
612 Some(i) => format!("{}/…", &url[..i]),
613 None => "…".to_string(),
614 },
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn a_mock_endpoint_is_loopback_or_nothing() {
624 assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
625 assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
626 for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
627 let e = Endpoint::mock(bad).unwrap_err();
628 assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
629 }
630 }
631
632 #[test]
633 fn both_spellings_of_a_mock_base_reach_the_same_door() {
634 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
635 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
636 }
637
638 #[test]
642 fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
643 for k in MOCK_ENVS {
644 let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
645 assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
646 }
647 assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
648 assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
650 }
651
652 #[test]
653 fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
654 let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
655 let args = m.child_args();
656 assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
657 assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
658 assert!(Endpoint::Account.child_args().is_empty());
659 assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
660 }
661
662 #[test]
663 fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
664 let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
665 assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
666 assert!(Endpoint::Account.banner().contains("a real bill"));
667 }
668
669 #[test]
670 fn the_stop_timeout_goes_out_as_a_string() {
671 let b = body::stop(Stop::Soft { timeout_s: 60 });
672 assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
673 assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
674 }
675
676 #[test]
677 fn a_label_filter_is_one_encoded_pair_per_label() {
678 assert_eq!(label_query(&[]), "");
679 assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
680 assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
681 }
682
683 #[test]
684 fn a_delete_says_what_happens_to_backups_in_one_spelling() {
685 assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
686 assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
687 assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
688 assert_eq!(delete_storage_query(Backups::Unsaid), "");
689 assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
690 }
691
692 #[test]
693 fn an_attach_names_an_address_only_when_asked() {
694 assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
695 assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
696 let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
697 assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
698 }
699
700 #[test]
701 fn an_upload_session_is_never_printed_whole() {
702 let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
703 assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
704 }
705
706 #[test]
707 fn a_failure_names_the_api_error_code() {
708 let r = Reply {
709 status: 409,
710 body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
711 text: String::new(),
712 };
713 let m = r.describe_failure("POST /server/{uuid}/storage/attach");
714 assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
715 let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
716 assert!(page.describe_failure("GET /x").contains("bad gateway"));
717 }
718}